7 Commits

Author SHA1 Message Date
Evan Jarrett
2ee52ceffd try with podman instead 2025-10-13 22:02:37 -05:00
Evan Jarrett
a2bcc06b79 try and push images to atcr.io 2025-10-13 21:22:21 -05:00
Evan Jarrett
6359edaf20 add hero banner, fix up css styles 2025-10-13 20:59:14 -05:00
Evan Jarrett
64a05d4024 clean up documentation 2025-10-13 17:07:08 -05:00
Evan Jarrett
4dc66c09a9 fix record not found error handling 2025-10-13 11:24:59 -05:00
Evan Jarrett
8c048d6279 implement writes for everyone 2025-10-13 10:40:03 -05:00
Evan Jarrett
ef0d830dc6 update install scripts 2025-10-12 23:35:33 -05:00
32 changed files with 2333 additions and 4350 deletions

View File

@@ -2,7 +2,7 @@
# Triggers on version tags and builds cross-platform binaries using GoReleaser
when:
- event: ["manual"]
- event: ["push", "manual"]
# TODO: Trigger only on version tags (v1.0.0, v2.1.3, etc.)
branch: ["main"]
@@ -12,20 +12,44 @@ dependencies:
nixpkgs:
- git
- go
- goreleaser
#- goreleaser
- podman
steps:
- name: Fetch git tags
command: git fetch --tags --force
- name: Checkout latest tag
command: git checkout $(git tag --sort=-version:refname | head -n1)
- name: Checkout tag for current commit
command: |
CURRENT_COMMIT=$(git rev-parse HEAD)
export TAG=$(git tag --points-at $CURRENT_COMMIT --sort=-version:refname | head -n1)
if [ -z "$TAG" ]; then
echo "Error: No tag found for commit $CURRENT_COMMIT"
exit 1
fi
echo "Found tag $TAG for commit $CURRENT_COMMIT"
git checkout $TAG
- name: Tidy Go modules
command: go mod tidy
- name: Build AppView Docker image
command: |
TAG=$(git describe --tags --exact-match 2>/dev/null || git tag --points-at HEAD | head -n1)
podman login atcr.io -u evan.jarrett.net -p ${APP_PASSWORD}
podman build -f Dockerfile.appview -t atcr.io/evan.jarrett.net/atcr-appview:${TAG} .
podman push atcr.io/evan.jarrett.net/atcr-appview:${TAG}
- name: Install Goat
command: go install github.com/bluesky-social/goat@latest
- name: Build Hold Docker image
command: |
TAG=$(git describe --tags --exact-match 2>/dev/null || git tag --points-at HEAD | head -n1)
podman login atcr.io -u evan.jarrett.net -p ${APP_PASSWORD}
podman build -f Dockerfile.hold -t atcr.io/evan.jarrett.net/atcr-hold:${TAG} .
podman push atcr.io/evan.jarrett.net/atcr-hold:${TAG}
# disable for now
# - name: Tidy Go modules
# command: go mod tidy
- name: Run GoReleaser
command: goreleaser release --clean
# - name: Install Goat
# command: go install github.com/bluesky-social/goat@latest
# - name: Run GoReleaser
# command: goreleaser release --clean

View File

@@ -92,13 +92,13 @@ ATCR uses **distribution/distribution** as a library and extends it through midd
```
1. Client: docker push atcr.io/alice/myapp:latest
2. HTTP Request → /v2/alice/myapp/manifests/latest
3. Registry Middleware (pkg/middleware/registry.go)
3. Registry Middleware (pkg/appview/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)
4. Routing Repository (pkg/appview/storage/routing_repository.go)
→ Creates RoutingRepository
→ Returns ATProto ManifestStore for manifests
→ Returns ProxyBlobStore for blobs
@@ -151,17 +151,22 @@ Resolution happens in `pkg/atproto/resolver.go`:
### Middleware System
ATCR uses two levels of middleware:
ATCR uses middleware and routing to handle requests:
#### 1. Registry Middleware (`pkg/middleware/registry.go`)
#### 1. Registry Middleware (`pkg/appview/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`
#### 2. Auth Middleware (`pkg/appview/middleware/auth.go`)
- Validates JWT tokens from Docker clients
- Extracts DID from token claims
- Injects authenticated identity into context
#### 3. Routing Repository (`pkg/appview/storage/routing_repository.go`)
- Implements `distribution.Repository`
- Returns custom `Manifests()` and `Blobs()` implementations
- Routes manifests to ATProto, blobs to S3 or BYOS
@@ -208,8 +213,8 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
**Authentication Flow:**
```
1. User runs: docker-credential-atcr configure
2. Helper generates ECDSA P-256 DPoP key
1. User configures Docker to use the credential helper (adds to config.json)
2. On first docker push/pull, 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
@@ -217,7 +222,7 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea
7. Exchange code for token with DPoP proof
8. Save: access token, refresh token, DPoP key, DID, handle
Later (docker push):
Later (subsequent docker push):
9. Docker calls credential helper
10. Helper loads token, refreshes if needed
11. Helper calls /auth/exchange with OAuth token + handle
@@ -266,7 +271,7 @@ Later (docker push):
- Digest-based addressing (sha256:abc123 → record key)
- Converts between OCI and ATProto formats
#### Storage Layer (`pkg/storage/`)
#### Storage Layer (`pkg/appview/storage/`)
**routing_repository.go**: Routes content by type
- `Manifests()` → returns ATProto ManifestStore (caches instance for hold endpoint extraction)
@@ -283,15 +288,11 @@ Later (docker push):
- **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
- Supports multipart uploads for large blobs
- Used when user has `io.atcr.hold` record
#### AppView Web UI (`pkg/appview/`)
@@ -484,15 +485,13 @@ See `.env.hold.example` for all available options. Key environment variables:
### Development Notes
**General:**
- Middleware is registered via `init()` functions in `pkg/middleware/`
- Import `_ "atcr.io/pkg/middleware"` in main.go to register middleware
- Middleware is in `pkg/appview/middleware/` (auth.go, registry.go)
- Storage routing is in `pkg/appview/storage/` (routing_repository.go, proxy_blob_store.go, hold_cache.go)
- 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
- Hold service reuses distribution's driver factory for multi-backend support
**OAuth implementation:**
- Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration
- 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
- All ATCR components use standardized `/auth/oauth/callback` path
- Client ID generation (localhost query-based vs production metadata URL) handled internally
@@ -514,13 +513,13 @@ When writing tests:
4. Update client methods if needed
**Modifying storage routing**:
1. Edit `pkg/storage/routing_repository.go`
1. Edit `pkg/appview/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
2. Update `pkg/appview/middleware/registry.go` if changing routing logic
3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records
**Working with OAuth client**:

370
README.md
View File

@@ -1,239 +1,59 @@
# ATCR - ATProto Container Registry
A container registry that uses the AT Protocol (ATProto) for manifest storage and S3 for blob storage.
An OCI-compliant container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
## Overview
## What is ATCR?
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.
ATCR integrates container registries with the AT Protocol ecosystem. Container image manifests are stored as ATProto records in your Personal Data Server (PDS), while layers are stored in S3-compatible storage.
### Architecture
**Image names use your ATProto identity:**
```
atcr.io/alice.bsky.social/myapp:latest
atcr.io/did:plc:xyz123/myapp:latest
```
ATCR consists of three main components:
## Architecture
1. **AppView** - OCI registry server + web UI
**Three components:**
1. **AppView** - Registry API + web UI
- Serves OCI Distribution API (Docker push/pull)
- Resolves identities (handle/DID PDS endpoint)
- Routes manifests to user's PDS, blobs to storage
- Web interface for browsing and search
- SQLite database for stars, pulls, metadata
- Resolves handles/DIDs to PDS endpoints
- Routes manifests to PDS, blobs to storage
- Web interface for browsing/search
2. **Hold Service** - Optional storage service (BYOS)
- Lightweight HTTP server for presigned URLs
- Supports S3, Storj, Minio, filesystem, etc.
- Authorization via ATProto records
- Users can deploy their own hold
2. **Hold Service** - Storage service (optional BYOS)
- Generates presigned URLs for S3/Storj/Minio/etc.
- Users can deploy their own storage
3. **Credential Helper** - Client-side OAuth
3. **Credential Helper** - Client authentication
- ATProto OAuth with DPoP
- Exchanges OAuth token for registry JWT
- Seamless Docker integration
- Automatic authentication on first push/pull
**Storage Model:**
- **Manifests** → ATProto records in user PDSs (small JSON metadata)
- **Blobs/Layers** → S3 or user's hold service (large binary data)
- **Name Resolution** → Supports both handles and DIDs
- `atcr.io/alice.bsky.social/myimage:latest`
- `atcr.io/did:plc:xyz123/myimage:latest`
**Storage model:**
- Manifests → ATProto records (small JSON)
- Blobs → S3 or BYOS (large binaries)
## Features
### Core Registry
- **OCI Distribution Spec compliant** - Works with Docker, containerd, podman
- **ATProto-native manifest storage** - Manifests stored as records in user PDSs
- **Hybrid storage** - Small manifests in ATProto, large blobs in S3/BYOS
- **DID/handle resolution** - Supports both handles and DIDs for image names
- **Decentralized ownership** - Users own their manifest data via their PDS
-**OCI-compliant** - Works with Docker, containerd, podman
- **Decentralized** - You own your manifest data via your PDS
- **ATProto OAuth** - Secure authentication with DPoP
- **BYOS** - Deploy your own storage service
- **Web UI** - Browse, search, star repositories
- **Multi-backend** - S3, Storj, Minio, Azure, GCS, filesystem
### Web Interface
- **Repository browser** - Browse and search container images
- **Star repositories** - Favorite images for quick access
- **Pull tracking** - View popularity and usage metrics
- **OAuth authentication** - Sign in with your ATProto identity
- **User profiles** - Manage your default storage hold
## Quick Start
### Authentication
- **ATProto OAuth with DPoP** - Cryptographic proof-of-possession tokens
- **Docker credential helper** - Seamless `docker push/pull` workflow
- **Token exchange** - OAuth tokens converted to registry JWTs
### Storage
- **BYOS (Bring Your Own Storage)** - Deploy your own hold service
- **Multi-backend support** - S3, Storj, Minio, Azure, GCS, filesystem
- **Presigned URLs** - Direct client-to-storage uploads/downloads
- **Hold discovery** - Automatic routing based on user preferences
## Building
### Using the Registry
**1. Install credential helper:**
```bash
# Build all binaries locally
go build -o atcr-appview ./cmd/appview
go build -o atcr-hold ./cmd/hold
go build -o docker-credential-atcr ./cmd/credential-helper
# Build Docker images
docker build -t atcr.io/appview:latest .
docker build -f Dockerfile.hold -t atcr.io/hold:latest .
```
**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-appview ./cmd/appview
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-appview 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:**
```bash
./atcr-appview 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:**
```bash
docker run -d \
--name atcr-appview \
-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/appview: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-appview
spec:
replicas: 3
selector:
matchLabels:
app: atcr-appview
template:
metadata:
labels:
app: atcr-appview
spec:
containers:
- name: appview
image: atcr.io/appview: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
## Installing Credential Helper
**Quick Install:**
```bash
# Linux/macOS
curl -fsSL https://atcr.io/install.sh | bash
# Windows (PowerShell as Administrator)
iwr -useb https://atcr.io/install.ps1 | iex
```
For detailed installation instructions (Homebrew, manual install, etc.), see **[INSTALLATION.md](./INSTALLATION.md)**.
**Configure Docker:**
```bash
# Add to ~/.docker/config.json
**2. Configure Docker** (add to `~/.docker/config.json`):
```json
{
"credHelpers": {
"atcr.io": "atcr"
@@ -241,89 +61,79 @@ For detailed installation instructions (Homebrew, manual install, etc.), see **[
}
```
## Usage
### Authenticate
**3. Push/pull images:**
```bash
# Auto-authentication on first push/pull
docker push atcr.io/yourhandle/myapp:latest
docker tag myapp:latest atcr.io/yourhandle/myapp:latest
docker push atcr.io/yourhandle/myapp:latest # Authenticates automatically
docker pull atcr.io/yourhandle/myapp:latest
```
### Pushing an Image
See **[INSTALLATION.md](./INSTALLATION.md)** for detailed installation instructions.
### Running Your Own AppView
**Using Docker Compose:**
```bash
# Tag your image
docker tag myapp:latest atcr.io/alice/myapp:latest
# Push to ATCR (credential helper handles auth)
docker push atcr.io/alice/myapp:latest
cp .env.appview.example .env.appview
# Edit .env.appview with your configuration
docker-compose up -d
```
### Pulling an Image
**Local development:**
```bash
# Pull from ATCR
docker pull atcr.io/alice/myapp:latest
# Build
go build -o bin/atcr-appview ./cmd/appview
go build -o bin/atcr-hold ./cmd/hold
# Configure
cp .env.appview.example .env.appview
# Edit .env.appview - set ATCR_DEFAULT_HOLD
source .env.appview
# Run
./bin/atcr-appview serve
```
### Web Interface
Visit the AppView URL (default: http://localhost:5000) to:
- Browse repositories
- Search for images
- Star your favorites
- View pull statistics
- Manage your storage settings
See **[deploy/README.md](./deploy/README.md)** for production deployment.
## Development
### Building from Source
```bash
# Build all binaries
go build -o bin/atcr-appview ./cmd/appview
go build -o bin/atcr-hold ./cmd/hold
go build -o bin/docker-credential-atcr ./cmd/credential-helper
# Run tests
go test ./...
go test -race ./...
```
### Project Structure
```
atcr.io/
├── cmd/
│ ├── appview/ # AppView entrypoint (registry + web UI)
│ ├── hold/ # Hold service entrypoint (BYOS)
│ └── credential-helper/ # Docker credential helper
├── pkg/
│ ├── appview/ # Web UI components
│ │ ├── handlers/ # HTTP handlers (home, repo, search, auth)
│ │ ├── db/ # SQLite database layer
│ │ ├── jetstream/ # ATProto Jetstream consumer
│ │ ├── static/ # JS, CSS assets
│ │ └── templates/ # HTML templates
│ ├── atproto/ # ATProto integration
│ │ ├── client.go # PDS client
│ │ ├── resolver.go # DID/handle resolution
│ │ ├── manifest_store.go # OCI manifest store
│ │ ├── lexicon.go # ATProto record schemas
│ │ └── profile.go # Sailor profile management
│ ├── storage/ # Storage layer
│ │ ├── routing_repository.go # Routes manifests/blobs
│ │ ├── proxy_blob_store.go # BYOS proxy
│ │ ├── s3_blob_store.go # S3 wrapper
│ │ └── hold_cache.go # Hold endpoint cache
│ ├── middleware/ # Registry middleware
│ │ ├── registry.go # Name resolution
│ │ └── repository.go # Storage routing
│ └── auth/ # Authentication
│ ├── oauth/ # ATProto OAuth with DPoP
│ ├── token/ # JWT issuer/validator
│ └── atproto/ # Session validation
├── config/ # Configuration files
├── docs/ # Documentation
└── Dockerfile
```
cmd/
├── appview/ # Registry server + web UI
├── hold/ # Storage service (BYOS)
└── credential-helper/ # Docker credential helper
### Testing
```bash
# Run tests
go test ./...
# Run with race detector
go test -race ./...
pkg/
├── appview/
│ ├── db/ # SQLite database (migrations, queries, stores)
│ ├── handlers/ # HTTP handlers (home, repo, search, auth, settings)
│ ├── jetstream/ # ATProto Jetstream consumer
│ ├── middleware/ # Auth & registry middleware
│ ├── storage/ # Storage routing (hold cache, blob proxy, repository)
│ ├── static/ # Static assets (JS, CSS, install scripts)
│ └── templates/ # HTML templates
├── atproto/ # ATProto client, records, manifest/tag stores
├── auth/
│ ├── oauth/ # OAuth client, server, refresher, storage
│ ├── token/ # JWT issuer, validator, claims
│ └── atproto/ # Session validation
└── hold/ # Hold service (authorization, storage, multipart, S3)
```
## License

View File

@@ -90,15 +90,13 @@ func main() {
redirectURI := cfg.Server.PublicURL + "/auth/oauth/callback"
clientID := cfg.Server.PublicURL + "/client-metadata.json"
// Define scopes needed for hold registration
// Define scopes needed for hold registration and crew management
// Omit action parameter to allow all actions (create, update, delete)
scopes := []string{
"atproto",
fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection),
fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection),
fmt.Sprintf("repo:%s", atproto.HoldCollection),
fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
}
config := indigooauth.NewPublicConfig(clientID, redirectURI, scopes)
@@ -148,6 +146,11 @@ func main() {
} else {
log.Printf("Successfully registered hold service in PDS")
}
// Reconcile allow-all crew state
if err := service.ReconcileAllowAllCrew(&oauthCallbackHandler); err != nil {
log.Printf("WARNING: Failed to reconcile allow-all crew state: %v", err)
}
}
// Wait for server error or shutdown

View File

@@ -35,6 +35,34 @@ HOLD_OWNER=did:plc:pddp4xt5lgnv2qsegbzzs4xg
# Default: false (private)
HOLD_PUBLIC=false
# Allow all authenticated users to write to this hold
# This setting controls write permissions for authenticated ATCR users
#
# - true: Any authenticated ATCR user can push images (treat all as crew)
# Useful for shared/community holds where you want to allow
# multiple users to push without explicit crew membership.
# Users must still authenticate via ATProto OAuth.
#
# - false: Only hold owner and explicit crew members can push (default)
# Write access requires io.atcr.hold.crew record in owner's PDS.
# Most secure option for production holds.
#
# Read permissions are controlled by HOLD_PUBLIC (above).
#
# Security model:
# Read: HOLD_PUBLIC=true → anonymous + authenticated users
# HOLD_PUBLIC=false → authenticated users only
# Write: HOLD_ALLOW_ALL_CREW=true → all authenticated users
# HOLD_ALLOW_ALL_CREW=false → owner + crew only (verified via PDS)
#
# Use cases:
# - Public registry: HOLD_PUBLIC=true, HOLD_ALLOW_ALL_CREW=true
# - ATProto users only: HOLD_PUBLIC=false, HOLD_ALLOW_ALL_CREW=true
# - Private hold (default): HOLD_PUBLIC=false, HOLD_ALLOW_ALL_CREW=false
#
# Default: false
HOLD_ALLOW_ALL_CREW=false
# ==============================================================================
# S3/UpCloud Object Storage Configuration
# ==============================================================================

View File

@@ -204,11 +204,13 @@ On your local machine:
# (Build from source or download release)
go install atcr.io/cmd/docker-credential-atcr@latest
# Configure Docker
docker-credential-atcr configure
# Enter your ATProto handle when prompted
# Complete OAuth flow in browser
# Configure Docker to use the credential helper
# Add to ~/.docker/config.json:
{
"credHelpers": {
"atcr.io": "atcr"
}
}
```
#### Test 3: Push a test image
@@ -409,17 +411,6 @@ docker logs -f atcr-hold
# Then push an image
```
## Security Hardening
### Firewall
```bash
# Allow only necessary ports
firewall-cmd --permanent --remove-service=cockpit
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="your-ip" service name="ssh" accept'
firewall-cmd --reload
```
### Automatic Updates
```bash
@@ -472,32 +463,8 @@ docker run --rm \
- PostgreSQL for UI database (replace SQLite)
- Multiple hold services (geo-distributed)
## Cost Estimation
**UpCloud Server:**
- 2 GB RAM / 1 CPU: ~$15/month
- 4 GB RAM / 2 CPU: ~$30/month
**UpCloud Object Storage:**
- Storage: $0.01/GB/month
- Egress: $0.01/GB (first 1TB free in some regions)
**Example monthly cost:**
- Server: $15
- Storage (100GB): $1
- Transfer (500GB): $5
- **Total: ~$21/month**
**Bandwidth optimization:**
- Presigned URLs mean hold service uses minimal bandwidth
- Most costs are S3 storage + transfer (not server bandwidth)
## Support
- Documentation: https://tangled.org/@evan.jarrett.net/at-container-registry
- Issues: https://github.com/your-org/atcr.io/issues
- Bluesky: @yourhandle.bsky.social
## License
MIT
- Issues: https://tangled.org/@evan.jarrett.net/at-container-registry/issues
- Bluesky: @evan.jarrett.net

View File

@@ -94,6 +94,7 @@ services:
# Hold service configuration
HOLD_PUBLIC_URL: https://${HOLD_DOMAIN:-hold01.atcr.io}
HOLD_SERVER_ADDR: :8080
HOLD_ALLOW_ALL_CREW: ${HOLD_ALLOW_ALL_CREW:-false}
HOLD_PUBLIC: ${HOLD_PUBLIC:-false}
HOLD_OWNER: ${HOLD_OWNER}

View File

@@ -1,434 +0,0 @@
# AppView-Mediated OAuth Architecture
## Overview
ATCR uses a two-tier authentication model to support OAuth while allowing the AppView to write manifests to users' Personal Data Servers (PDS).
## The Problem
OAuth with DPoP creates cryptographically bound tokens that cannot be delegated:
- **Basic Auth**: App password is a shared secret that can be forwarded from client → AppView → PDS ✅
- **OAuth + DPoP**: Token is bound to client's keypair and cannot be reused by AppView ❌
This creates a challenge: How can the AppView write manifests to the user's PDS on their behalf?
## The Solution: Two-Tier Authentication
```
┌──────────┐ ┌─────────┐ ┌────────────┐
│ Docker │◄───────►│ AppView │◄───────►│ PDS/Auth │
│ Client │ Auth1 │ (ATCR) │ Auth2 │ Server │
└──────────┘ └─────────┘ └────────────┘
```
**Auth Tier 1** (Docker ↔ AppView): Registry authentication
- Client authenticates to AppView using session tokens
- AppView issues short-lived registry JWTs
- Standard Docker registry auth protocol
**Auth Tier 2** (AppView ↔ PDS): Resource access
- AppView acts as OAuth client for each user
- AppView stores refresh tokens per user
- AppView gets access tokens on-demand to write manifests
## Complete Flows
### One-Time Authorization Flow
```
┌────────┐ ┌──────────────┐ ┌─────────┐ ┌─────┐
│ User │ │ Credential │ │ AppView │ │ PDS │
│ │ │ Helper │ │ │ │ │
└───┬────┘ └──────┬───────┘ └────┬────┘ └──┬──┘
│ │ │ │
│ $ docker-credential-atcr configure │ │
│ Enter handle: evan.jarrett.net │ │
│─────────────────────>│ │ │
│ │ │ │
│ │ GET /auth/oauth/authorize?handle=... │
│ │─────────────────────>│ │
│ │ │ │
│ │ 302 Redirect to PDS │ │
│ │<─────────────────────│ │
│ │ │ │
│ [Browser opens] │ │ │
│<─────────────────────│ │ │
│ │ │ │
│ Authorize ATCR? │ │ │
│──────────────────────────────────────────────────────────────>│
│ │ │ │
│ │ │<─code────────────│
│ │ │ │
│ │ │ POST /token │
│ │ │ (exchange code) │
│ │ │ + DPoP proof │
│ │ │─────────────────>│
│ │ │ │
│ │ │<─refresh_token───│
│ │ │ access_token │
│ │ │ │
│ │ │ [Store tokens] │
│ │ │ DID → { │
│ │ │ refresh_token, │
│ │ │ dpop_key, │
│ │ │ pds_endpoint │
│ │ │ } │
│ │ │ │
│ │<─session_token───────│ │
│ │ │ │
│ [Store session] │ │ │
│<─────────────────────│ │ │
│ ~/.atcr/ │ │ │
│ session.json │ │ │
│ │ │ │
│ ✓ Authorization │ │ │
│ complete! │ │ │
│ │ │ │
```
### Docker Push Flow (Every Push)
```
┌────────┐ ┌──────────┐ ┌─────────┐ ┌─────┐
│ Docker │ │ Cred │ │ AppView │ │ PDS │
│ │ │ Helper │ │ │ │ │
└───┬────┘ └────┬─────┘ └────┬────┘ └──┬──┘
│ │ │ │
│ docker push │ │ │
│──────────────>│ │ │
│ │ │ │
│ │ GET /auth/exchange │
│ │ Authorization: Bearer │
│ │ <session_token> │
│ │──────────────>│ │
│ │ │ │
│ │ │ [Validate │
│ │ │ session] │
│ │ │ │
│ │ │ [Issue JWT] │
│ │ │ │
│ │<──registry_jwt─│ │
│ │ │ │
│<─registry_jwt─│ │ │
│ │ │ │
│ PUT /v2/.../manifests/... │ │
│ Authorization: Bearer │ │
│ <registry_jwt> │ │
│──────────────────────────────>│ │
│ │ │
│ │ [Validate │
│ │ JWT] │
│ │ │
│ │ [Get fresh │
│ │ access │
│ │ token] │
│ │ │
│ │ POST /token │
│ │ (refresh) │
│ │ + DPoP │
│ │────────────>│
│ │ │
│ │<access_token│
│ │ │
│ │ PUT record │
│ │ (manifest) │
│ │ + DPoP │
│ │────────────>│
│ │ │
│ │<──201 OK────│
│ │ │
│<──────────201 OK──────────────│ │
│ │ │
```
## Components
### 1. OAuth Authorization Server (AppView)
**File**: `pkg/auth/oauth/server.go`
**Endpoints**:
#### `GET /auth/oauth/authorize`
Initiates OAuth flow for a user.
**Query Parameters**:
- `handle` (required): User's ATProto handle (e.g., `evan.jarrett.net`)
**Flow**:
1. Resolve handle → DID → PDS endpoint
2. Discover PDS OAuth metadata
3. Generate state + PKCE verifier
4. Create PAR request to PDS
5. Redirect user to PDS authorization endpoint
**Response**: `302 Redirect` to PDS authorization page
#### `GET /auth/oauth/callback`
Receives OAuth callback from PDS.
**Query Parameters**:
- `code`: Authorization code
- `state`: State for CSRF protection
**Flow**:
1. Validate state
2. Exchange code for tokens (POST to PDS token endpoint)
3. Use AppView's DPoP key for the exchange
4. Store refresh token + DPoP key for user's DID
5. Generate AppView session token
6. Redirect to success page with session token
**Response**: HTML page with session token (user copies to credential helper)
### 2. Refresh Token Storage
**File**: `pkg/auth/oauth/storage.go`
**Storage Format**:
```json
{
"refresh_tokens": {
"did:plc:abc123": {
"refresh_token": "...",
"dpop_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...",
"pds_endpoint": "https://bsky.social",
"handle": "evan.jarrett.net",
"created_at": "2025-10-04T...",
"last_refreshed": "2025-10-04T..."
}
}
}
```
**Location**:
- Development: `~/.atcr/appview-tokens.json`
- Production: Encrypted database or secret manager
**Security**:
- File permissions: `0600` (owner read/write only)
- Consider encrypting DPoP keys at rest
- Rotate refresh tokens periodically
### 3. Token Refresher
**File**: `pkg/auth/oauth/refresher.go`
**Interface**:
```go
type Refresher interface {
// GetAccessToken gets a fresh access token for a DID
// Returns cached token if still valid, otherwise refreshes
GetAccessToken(ctx context.Context, did string) (token string, dpopKey *ecdsa.PrivateKey, err error)
// RefreshToken forces a token refresh
RefreshToken(ctx context.Context, did string) error
// RevokeToken removes stored refresh token
RevokeToken(did string) error
}
```
**Caching Strategy**:
- Access tokens cached for 14 minutes (expire at 15min)
- Refresh tokens stored persistently
- Cache key: `did → {access_token, dpop_key, expires_at}`
### 4. Session Management
**File**: `pkg/auth/session/handler.go`
**Session Token Format**:
```
Base64(JSON({
"did": "did:plc:abc123",
"handle": "evan.jarrett.net",
"issued_at": "2025-10-04T...",
"expires_at": "2025-11-03T..." // 30 days
})).HMAC-SHA256(secret)
```
**Storage**: Stateless (validated by HMAC signature)
**Endpoints**:
#### `GET /auth/session/validate`
Validates a session token.
**Headers**:
- `Authorization: Bearer <session_token>`
**Response**:
```json
{
"did": "did:plc:abc123",
"handle": "evan.jarrett.net",
"valid": true
}
```
### 5. Updated Exchange Handler
**File**: `pkg/auth/exchange/handler.go`
**Changes**:
- Accept session token instead of OAuth token
- Validate session token → extract DID
- Issue registry JWT with DID
- Remove PDS token validation
**Request**:
```
POST /auth/exchange
Authorization: Bearer <session_token>
{
"scope": ["repository:*:pull,push"]
}
```
**Response**:
```json
{
"token": "<registry-jwt>",
"expires_in": 900
}
```
### 6. Credential Helper Updates
**File**: `cmd/credential-helper/main.go`
**Changes**:
1. **Configure command**:
- Open browser to AppView: `http://127.0.0.1:5000/auth/oauth/authorize?handle=...`
- User authorizes on PDS
- AppView displays session token
- User copies session token to helper
- Helper stores session token
2. **Get command**:
- Load session token from `~/.atcr/session.json`
- Call `/auth/exchange` with session token
- Return registry JWT to Docker
3. **Storage format**:
```json
{
"session_token": "...",
"handle": "evan.jarrett.net",
"appview_url": "http://127.0.0.1:5000"
}
```
**Removed**:
- DPoP key generation
- OAuth client logic
- Refresh token handling
## Security Considerations
### AppView as Trusted Component
The AppView becomes a **trusted intermediary** that:
- Stores refresh tokens for users
- Acts on users' behalf to write manifests
- Issues registry authentication tokens
**Trust model**:
- Users must trust the AppView operator
- Similar to trusting a Docker registry operator
- AppView has write access to manifests (not profile data)
### Scope Limitations
AppView OAuth tokens are requested with minimal scopes:
- `atproto` - Basic ATProto operations
- Only needs: `com.atproto.repo.putRecord`, `com.atproto.repo.getRecord`
- Does NOT need: profile updates, social graph access, etc.
### Token Security
**Refresh Tokens**:
- Stored encrypted at rest
- File permissions: 0600
- Rotated periodically (when used)
- Can be revoked by user on PDS
**Session Tokens**:
- 30-day expiry
- HMAC-signed (stateless validation)
- Can be revoked by clearing storage
**Access Tokens**:
- Cached in-memory only
- 15-minute expiry
- Never stored persistently
### Audit Trail
AppView should log:
- OAuth authorizations (DID, timestamp)
- Token refreshes (DID, timestamp)
- Manifest writes (DID, repository, timestamp)
## Migration from Current OAuth
Users currently using `docker-credential-atcr` with direct PDS OAuth will need to:
1. Run `docker-credential-atcr configure` again
2. Authorize AppView (new OAuth flow)
3. Old PDS tokens are no longer used
## Alternative: Bring Your Own AppView
Users who don't trust a shared AppView can:
1. Run their own ATCR AppView instance
2. Configure credential helper to point at their AppView
3. Their AppView stores their refresh tokens locally
## Future Enhancements
### Multi-AppView Support
Allow users to configure multiple AppViews:
```json
{
"appviews": {
"default": "https://atcr.io",
"personal": "http://localhost:5000"
},
"sessions": {
"https://atcr.io": {"session_token": "...", "handle": "..."},
"http://localhost:5000": {"session_token": "...", "handle": "..."}
}
}
```
### Refresh Token Rotation
Implement automatic refresh token rotation per OAuth best practices:
- PDS issues new refresh token with each use
- AppView updates stored token
- Old refresh token invalidated
### Revocation UI
Add web UI for users to:
- View active sessions
- Revoke AppView access
- See audit log of manifest writes
## References
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)

1231
docs/CREW_ACCESS_CONTROL.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,460 +0,0 @@
# Hold Service Multipart Upload Architecture
## Overview
The hold service supports multipart uploads through two modes:
1. **S3Native** - Uses S3's native multipart API with presigned URLs (optimal)
2. **Buffered** - Buffers parts in hold service memory, assembles on completion (fallback)
This dual-mode approach enables the hold service to work with:
- S3-compatible storage with presigned URL support (S3, Storj, MinIO, etc.)
- S3-compatible storage WITHOUT presigned URL support
- Filesystem storage
- Any storage driver supported by distribution
## Current State
### What Works ✅
- **S3 Native Mode with presigned URLs**: Fully working! Direct uploads to S3 via presigned URLs
- **Buffered mode with S3**: Tested and working with `DISABLE_PRESIGNED_URLS=true`
- **Filesystem storage**: Tested and working! Buffered mode with filesystem driver
- **AppView multipart client**: Implements chunked uploads via multipart API
- **MultipartManager**: Session tracking, automatic cleanup, thread-safe operations
- **Automatic fallback**: Falls back to buffered mode when S3 unavailable or disabled
- **ETag normalization**: Handles quoted/unquoted ETags from S3
- **Route handler**: `/multipart-parts/{uploadID}/{partNumber}` endpoint added and tested
### All Implementation Complete! 🎉
All three multipart upload modes are fully implemented, tested, and working in production.
### Bugs Fixed 🔧
- **Missing S3 parts in complete**: For S3Native mode, parts uploaded directly to S3 weren't being recorded. Fixed by storing parts from request in `HandleCompleteMultipart` before calling `CompleteMultipartUploadWithManager`.
- **Malformed XML error from S3**: S3 requires ETags to be quoted in CompleteMultipartUpload XML. Added `normalizeETag()` function to ensure quotes are present.
- **Route missing**: `/multipart-parts/{uploadID}/{partNumber}` not registered in cmd/hold/main.go. Fixed by adding route handler with path parsing.
- **MultipartMgr access**: Field was private, preventing route handler access. Fixed by exporting as `MultipartMgr`.
- **DISABLE_PRESIGNED_URLS not logged**: `initS3Client()` didn't check the flag before initializing. Fixed with early return check and proper logging.
## Architecture
### Three Modes of Operation
#### Mode 1: S3 Native Multipart ✅ WORKING
```
Docker → AppView → Hold → S3 (presigned URLs)
Returns presigned URL
Docker ──────────→ S3 (direct upload)
```
**Flow:**
1. AppView: `POST /start-multipart` → Hold starts S3 multipart, returns uploadID
2. AppView: `POST /part-presigned-url` → Hold returns S3 presigned URL
3. Docker → S3: Direct upload via presigned URL
4. AppView: `POST /complete-multipart` → Hold calls S3 CompleteMultipartUpload
**Advantages:**
- No data flows through hold service
- Minimal bandwidth usage
- Fast uploads
#### Mode 2: S3 Proxy Mode (Buffered) ✅ WORKING
```
Docker → AppView → Hold → S3 (via driver)
Buffers & proxies
S3
```
**Flow:**
1. AppView: `POST /start-multipart` → Hold creates buffered session
2. AppView: `POST /part-presigned-url` → Hold returns proxy URL
3. Docker → Hold: `PUT /multipart-parts/{uploadID}/{part}` → Hold buffers
4. AppView: `POST /complete-multipart` → Hold uploads to S3 via driver
**Use Cases:**
- S3 provider doesn't support presigned URLs
- S3 API fails to generate presigned URL
- Fallback from Mode 1
#### Mode 3: Filesystem Mode ✅ WORKING
```
Docker → AppView → Hold (filesystem driver)
Buffers & writes
Local filesystem
```
**Flow:**
Same as Mode 2, but writes to filesystem driver instead of S3 driver.
**Use Cases:**
- Development/testing with local filesystem
- Small deployments without S3
- Air-gapped environments
## Implementation: pkg/hold/multipart.go
### Core Components
#### MultipartManager
```go
type MultipartManager struct {
sessions map[string]*MultipartSession
mu sync.RWMutex
}
```
**Responsibilities:**
- Track active multipart sessions
- Clean up abandoned uploads (>24h inactive)
- Thread-safe session access
#### MultipartSession
```go
type MultipartSession struct {
UploadID string // Unique ID for this upload
Digest string // Target blob digest
Mode MultipartMode // S3Native or Buffered
S3UploadID string // S3 upload ID (S3Native only)
Parts map[int]*MultipartPart // Buffered parts (Buffered only)
CreatedAt time.Time
LastActivity time.Time
}
```
**State Tracking:**
- S3Native: Tracks S3 upload ID and part ETags
- Buffered: Stores part data in memory
#### MultipartPart
```go
type MultipartPart struct {
PartNumber int // Part number (1-indexed)
Data []byte // Part data (Buffered mode only)
ETag string // S3 ETag or computed hash
Size int64
}
```
### Key Methods
#### StartMultipartUploadWithManager
```go
func (s *HoldService) StartMultipartUploadWithManager(
ctx context.Context,
digest string,
manager *MultipartManager,
) (string, MultipartMode, error)
```
**Logic:**
1. Try S3 native multipart via `s.startMultipartUpload()`
2. If successful → Create S3Native session
3. If fails or no S3 client → Create Buffered session
4. Return uploadID and mode
#### GetPartUploadURL
```go
func (s *HoldService) GetPartUploadURL(
ctx context.Context,
session *MultipartSession,
partNumber int,
did string,
) (string, error)
```
**Logic:**
- S3Native mode: Generate S3 presigned URL via `s.getPartPresignedURL()`
- Buffered mode: Return proxy endpoint `/multipart-parts/{uploadID}/{part}`
#### CompleteMultipartUploadWithManager
```go
func (s *HoldService) CompleteMultipartUploadWithManager(
ctx context.Context,
session *MultipartSession,
manager *MultipartManager,
) error
```
**Logic:**
- S3Native: Call `s.completeMultipartUpload()` with S3 API
- Buffered: Assemble parts in order, write via storage driver
#### HandleMultipartPartUpload (New Endpoint)
```go
func (s *HoldService) HandleMultipartPartUpload(
w http.ResponseWriter,
r *http.Request,
uploadID string,
partNumber int,
did string,
manager *MultipartManager,
)
```
**New HTTP endpoint:** `PUT /multipart-parts/{uploadID}/{partNumber}`
**Purpose:** Receive part uploads in Buffered mode
**Logic:**
1. Validate session exists and is in Buffered mode
2. Authorize write access
3. Read part data from request body
4. Store in session with computed ETag (SHA256)
5. Return ETag in response header
## Integration Plan
### Phase 1: Migrate to pkg/hold (COMPLETE)
- [x] Extract code from cmd/hold/main.go to pkg/hold/
- [x] Create isolated multipart.go implementation
- [x] Update cmd/hold/main.go to import pkg/hold
- [x] Test existing functionality works
### Phase 2: Add Buffered Mode Support (COMPLETE ✅)
- [x] Add MultipartManager to HoldService
- [x] Update handlers to use `*WithManager` methods
- [x] Add DISABLE_PRESIGNED_URLS environment variable for testing
- [x] Implement presigned URL disable checks in all methods
- [x] **Fixed: Record S3 parts from request in HandleCompleteMultipart**
- [x] **Fixed: ETag normalization (add quotes for S3 XML)**
- [x] **Test S3 native mode with presigned URLs** ✅ WORKING
- [x] **Add route in cmd/hold/main.go** ✅ COMPLETE
- [x] **Export MultipartMgr field for route handler access** ✅ COMPLETE
- [x] **Test DISABLE_PRESIGNED_URLS=true with S3 storage** ✅ WORKING
- [x] **Test filesystem storage with buffered multipart** ✅ WORKING
### Phase 3: Update AppView
- [ ] Detect hold capabilities (presigned vs proxy)
- [ ] Fallback to buffered mode when presigned fails
- [ ] Handle `/multipart-parts/` proxy URLs
### Phase 4: Capability Discovery
- [ ] Add capability endpoint: `GET /capabilities`
- [ ] Return: `{"multipart": "native|buffered|both", "storage": "s3|filesystem"}`
- [ ] AppView uses capabilities to choose upload strategy
## Testing Strategy
### Unit Tests
- [ ] MultipartManager session lifecycle
- [ ] Part buffering and assembly
- [ ] Concurrent part uploads (thread safety)
- [ ] Session cleanup (expired uploads)
### Integration Tests
**S3 Native Mode:**
- [x] Start multipart → get presigned URLs → upload parts → complete ✅ WORKING
- [x] Verify no data flows through hold service (only ~1KB API calls)
- [ ] Test abort cleanup
**Buffered Mode (S3 with DISABLE_PRESIGNED_URLS):**
- [x] Start multipart → get proxy URLs → upload parts → complete ✅ WORKING
- [x] Verify parts assembled correctly
- [ ] Test missing part detection
- [ ] Test abort cleanup
**Buffered Mode (Filesystem):**
- [x] Start multipart → get proxy URLs → upload parts → complete ✅ WORKING
- [x] Verify parts assembled correctly ✅ WORKING
- [x] Verify blobs written to filesystem ✅ WORKING
- [ ] Test missing part detection
- [ ] Test abort cleanup
### Load Tests
- [ ] Concurrent multipart uploads (multiple sessions)
- [ ] Large blobs (100MB+, many parts)
- [ ] Memory usage with many buffered parts
## Performance Considerations
### Memory Usage (Buffered Mode)
- Parts stored in memory until completion
- Docker typically uses 5MB chunks (S3 minimum)
- 100MB image = ~20 parts = ~100MB RAM during upload
- Multiple concurrent uploads multiply memory usage
**Mitigation:**
- Session cleanup (24h timeout)
- Consider disk-backed buffering for large parts (future optimization)
- Monitor memory usage and set limits
### Network Bandwidth
- S3Native: Minimal (only API calls)
- Buffered: Full blob data flows through hold service
- Filesystem: Always buffered (no presigned URL option)
## Configuration
### Environment Variables
**Current (S3 only):**
```bash
STORAGE_DRIVER=s3
S3_BUCKET=my-bucket
S3_ENDPOINT=https://s3.amazonaws.com
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
```
**Filesystem:**
```bash
STORAGE_DRIVER=filesystem
STORAGE_ROOT_DIR=/var/lib/atcr/hold
```
### Automatic Mode Selection
No configuration needed - hold service automatically:
1. Tries S3 native multipart if S3 client exists
2. Falls back to buffered mode if S3 unavailable or fails
3. Always uses buffered mode for filesystem driver
## Security Considerations
### Authorization
- All multipart operations require write authorization
- Buffered mode: Check auth on every part upload
- S3Native: Auth only on start/complete (presigned URLs have embedded auth)
### Resource Limits
- Max upload size: Controlled by storage backend
- Max concurrent uploads: Limited by memory
- Session timeout: 24 hours (configurable)
### Attack Vectors
- **Memory exhaustion**: Attacker uploads many large parts
- Mitigation: Session limits, cleanup, auth
- **Incomplete uploads**: Attacker starts but never completes
- Mitigation: 24h timeout, cleanup goroutine
- **Part flooding**: Upload many tiny parts
- Mitigation: S3 has 10,000 part limit, could add to buffered mode
## Future Enhancements
### Disk-Backed Buffering
Instead of memory, buffer parts to temporary disk location:
- Reduces memory pressure
- Supports larger uploads
- Requires cleanup on completion/abort
### Parallel Part Assembly
For large uploads, assemble parts in parallel:
- Stream parts to writer as they arrive
- Reduce memory footprint
- Faster completion
### Chunked Completion
For very large assembled blobs:
- Stream to storage driver in chunks
- Avoid loading entire blob in memory
- Use `io.Copy()` with buffer
### Multi-Backend Support
- Azure Blob Storage multipart
- Google Cloud Storage resumable uploads
- Backblaze B2 large file API
## Implementation Complete ✅
The buffered multipart mode is fully implemented with the following components:
**Route Handler** (`cmd/hold/main.go:47-73`):
- Endpoint: `PUT /multipart-parts/{uploadID}/{partNumber}`
- Parses URL path to extract uploadID and partNumber
- Delegates to `service.HandleMultipartPartUpload()`
**Exported Manager** (`pkg/hold/service.go:20`):
- Field `MultipartMgr` is now exported for route handler access
- All handlers updated to use `s.MultipartMgr`
**Configuration Check** (`pkg/hold/s3.go:20-25`):
- `initS3Client()` checks `DISABLE_PRESIGNED_URLS` flag before initializing
- Logs clear message when presigned URLs are disabled
- Prevents misleading "S3 presigned URLs enabled" message
## Testing Multipart Modes
### Test 1: S3 Native Mode (presigned URLs) ✅ TESTED
```bash
export STORAGE_DRIVER=s3
export S3_BUCKET=your-bucket
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
# Do NOT set DISABLE_PRESIGNED_URLS
# Start hold service
./bin/atcr-hold
# Push an image
docker push atcr.io/yourdid/test:latest
# Expected logs:
# "✅ S3 presigned URLs enabled"
# "Started S3 native multipart: uploadID=... s3UploadID=..."
# "Completed multipart upload: digest=... uploadID=... parts=..."
```
**Status**: ✅ Working - Direct uploads to S3, minimal bandwidth through hold service
### Test 2: Buffered Mode with S3 (forced proxy) ✅ TESTED
```bash
export STORAGE_DRIVER=s3
export S3_BUCKET=your-bucket
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export DISABLE_PRESIGNED_URLS=true # Force buffered mode
# Start hold service
./bin/atcr-hold
# Push an image
docker push atcr.io/yourdid/test:latest
# Expected logs:
# "⚠️ S3 presigned URLs DISABLED by config (DISABLE_PRESIGNED_URLS=true)"
# "Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode"
# "Stored part: uploadID=... part=1 size=..."
# "Assembled buffered parts: uploadID=... parts=... totalSize=..."
# "Completed buffered multipart: uploadID=... size=... written=..."
```
**Status**: ✅ Working - Parts buffered in hold service memory, assembled and written to S3 via driver
### Test 3: Filesystem Mode (always buffered) ✅ TESTED
```bash
export STORAGE_DRIVER=filesystem
export STORAGE_ROOT_DIR=/tmp/atcr-hold-test
# DISABLE_PRESIGNED_URLS not needed (filesystem never has presigned URLs)
# Start hold service
./bin/atcr-hold
# Push an image
docker push atcr.io/yourdid/test:latest
# Expected logs:
# "Storage driver is filesystem (not S3), presigned URLs disabled"
# "Started buffered multipart: uploadID=..."
# "Stored part: uploadID=... part=1 size=..."
# "Assembled buffered parts: uploadID=... parts=... totalSize=..."
# "Completed buffered multipart: uploadID=... size=... written=..."
# Verify blobs written to:
ls -lh /var/lib/atcr/hold/docker/registry/v2/blobs/sha256/
# Or from outside container:
docker exec atcr-hold ls -lh /var/lib/atcr/hold/docker/registry/v2/blobs/sha256/
```
**Status**: ✅ Working - Parts buffered in memory, assembled, and written to filesystem via driver
**Note**: Initial HEAD requests will show "Path not found" errors - this is normal! Docker checks if blobs exist before uploading. The errors occur for blobs that haven't been uploaded yet. After upload, subsequent HEAD checks succeed.
## References
- S3 Multipart Upload API: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
- Distribution Storage Driver Interface: https://github.com/distribution/distribution/blob/main/registry/storage/driver/storagedriver.go
- OCI Distribution Spec (Blob Upload): https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks

View File

@@ -1,570 +0,0 @@
S3 Multipart Upload Implementation Plan
Problem Summary
Current implementation uses a single presigned URL with a pipe for chunked uploads (PATCH). This causes:
- Docker PATCH requests block waiting for pipe writes
- S3 upload happens in background via single presigned URL
- Docker times out → "client disconnected during blob PATCH"
- Root cause: Single presigned URLs don't support OCI's chunked upload protocol
Solution: S3 Multipart Upload API
Implement proper S3 multipart upload to support Docker's chunked PATCH operations:
- Each PATCH → separate S3 part upload with its own presigned URL
- On Commit → complete multipart upload
- No buffering, no pipes, no blocking
---
Architecture Changes
Current (Broken) Flow
POST /blobs/uploads/ → Create() → Single presigned URL to temp location
PATCH → Write to pipe → [blocks] → Background goroutine uploads via single URL
PATCH → [blocks on pipe] → Docker timeout → disconnect ❌
New (Multipart) Flow
POST /blobs/uploads/ → Create() → Initiate multipart upload, get upload ID
PATCH #1 → Get presigned URL for part 1 → Upload part 1 to S3 → Store ETag
PATCH #2 → Get presigned URL for part 2 → Upload part 2 to S3 → Store ETag
PUT (commit) → Complete multipart upload with ETags → Done ✅
---
Implementation Details
1. Hold Service: Add Multipart Upload Endpoints
File: cmd/hold/main.go
New Request/Response Types
// StartMultipartUploadRequest initiates a multipart upload
type StartMultipartUploadRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
}
type StartMultipartUploadResponse struct {
UploadID string `json:"upload_id"`
ExpiresAt time.Time `json:"expires_at"`
}
// GetPartURLRequest requests a presigned URL for a specific part
type GetPartURLRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
PartNumber int `json:"part_number"`
}
type GetPartURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// CompleteMultipartRequest completes a multipart upload
type CompleteMultipartRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
Parts []CompletedPart `json:"parts"`
}
type CompletedPart struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
// AbortMultipartRequest aborts an in-progress upload
type AbortMultipartRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
}
New Endpoints
POST /start-multipart
func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Build S3 key from digest
// Call s3.CreateMultipartUploadRequest()
// Generate presigned URL if needed, or return upload ID
// Return upload ID to client
}
POST /part-presigned-url
func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Build S3 key from digest
// Call s3.UploadPartRequest() with part number and upload ID
// Generate presigned URL
// Return presigned URL for this specific part
}
POST /complete-multipart
func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Build S3 key from digest
// Prepare CompletedPart array with part numbers and ETags
// Call s3.CompleteMultipartUpload()
// Return success
}
POST /abort-multipart (for cleanup)
func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Call s3.AbortMultipartUpload()
// Return success
}
S3 Implementation
// startMultipartUpload initiates a multipart upload and returns upload ID
func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) {
if s.s3Client == nil {
return "", fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
if err != nil {
return "", err
}
return *result.UploadId, nil
}
// getPartPresignedURL generates presigned URL for a specific part
func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
if s.s3Client == nil {
return "", fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
PartNumber: aws.Int64(int64(partNumber)),
})
return req.Presign(15 * time.Minute)
}
// completeMultipartUpload finalizes the multipart upload
func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
if s.s3Client == nil {
return fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
// Convert to S3 CompletedPart format
s3Parts := make([]*s3.CompletedPart, len(parts))
for i, p := range parts {
s3Parts[i] = &s3.CompletedPart{
PartNumber: aws.Int64(int64(p.PartNumber)),
ETag: aws.String(p.ETag),
}
}
_, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
MultipartUpload: &s3.CompletedMultipartUpload{
Parts: s3Parts,
},
})
return err
}
---
2. AppView: Rewrite ProxyBlobStore for Multipart
File: pkg/storage/proxy_blob_store.go
Remove Current Implementation
- Remove pipe-based streaming
- Remove background goroutine with single presigned URL
- Remove global upload tracking map
New ProxyBlobWriter Structure
type ProxyBlobWriter struct {
store *ProxyBlobStore
options distribution.CreateOptions
uploadID string // S3 multipart upload ID
parts []CompletedPart // Track uploaded parts with ETags
partNumber int // Current part number (starts at 1)
buffer *bytes.Buffer // Buffer for current part
size int64 // Total bytes written
closed bool
id string // Distribution's upload ID (for state)
startedAt time.Time
finalDigest string // Set on Commit
}
type CompletedPart struct {
PartNumber int
ETag string
}
New Create() - Initiate Multipart Upload
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
var opts distribution.CreateOptions
for _, option := range options {
if err := option.Apply(&opts); err != nil {
return nil, err
}
}
// Use temp digest for upload location
writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano())
tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", writerID))
// Start multipart upload via hold service
uploadID, err := p.startMultipartUpload(ctx, tempDigest)
if err != nil {
return nil, fmt.Errorf("failed to start multipart upload: %w", err)
}
writer := &ProxyBlobWriter{
store: p,
options: opts,
uploadID: uploadID,
parts: make([]CompletedPart, 0),
partNumber: 1,
buffer: bytes.NewBuffer(make([]byte, 0, 5*1024*1024)), // 5MB buffer
id: writerID,
startedAt: time.Now(),
}
// Store in global map for Resume()
globalUploadsMu.Lock()
globalUploads[writer.id] = writer
globalUploadsMu.Unlock()
return writer, nil
}
New Write() - Buffer and Flush Parts
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)
// Flush if buffer reaches 5MB (S3 minimum part size)
if w.buffer.Len() >= 5*1024*1024 {
if err := w.flushPart(); err != nil {
return n, err
}
}
return n, err
}
func (w *ProxyBlobWriter) flushPart() error {
if w.buffer.Len() == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Get presigned URL for this part
tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id))
url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber)
if err != nil {
return fmt.Errorf("failed to get part presigned URL: %w", err)
}
// Upload part to S3
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes()))
if err != nil {
return err
}
resp, err := w.store.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("part upload failed: status %d", resp.StatusCode)
}
// Store ETag for completion
etag := resp.Header.Get("ETag")
if etag == "" {
return fmt.Errorf("no ETag in response")
}
w.parts = append(w.parts, CompletedPart{
PartNumber: w.partNumber,
ETag: etag,
})
// Reset buffer and increment part number
w.buffer.Reset()
w.partNumber++
return nil
}
New Commit() - Complete Multipart and Move
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
// Flush any remaining buffered data
if w.buffer.Len() > 0 {
if err := w.flushPart(); err != nil {
// Try to abort multipart on error
w.store.abortMultipartUpload(ctx, w.uploadID)
return distribution.Descriptor{}, err
}
}
// Complete multipart upload at temp location
tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id))
if err := w.store.completeMultipartUpload(ctx, tempDigest, w.uploadID, w.parts); err != nil {
return distribution.Descriptor{}, err
}
// Move from temp → final location (server-side S3 copy)
tempPath := fmt.Sprintf("uploads/temp-%s", w.id)
finalPath := desc.Digest.String()
moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s",
w.store.storageEndpoint, tempPath, finalPath, w.store.did)
req, err := http.NewRequestWithContext(ctx, "POST", moveURL, nil)
if err != nil {
return distribution.Descriptor{}, err
}
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 {
bodyBytes, _ := io.ReadAll(resp.Body)
return distribution.Descriptor{}, fmt.Errorf("move failed: %d, %s", resp.StatusCode, bodyBytes)
}
// Remove from global map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
return distribution.Descriptor{
Digest: desc.Digest,
Size: w.size,
MediaType: desc.MediaType,
}, nil
}
Add Hold Service Client Methods
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, dgst digest.Digest) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
UploadID string `json:"upload_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.UploadID, nil
}
func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, dgst digest.Digest, uploadID string, partNumber int) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"upload_id": uploadID,
"part_number": partNumber,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.URL, nil
}
func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string, parts []CompletedPart) error {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"upload_id": uploadID,
"parts": parts,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
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("complete multipart failed: status %d", resp.StatusCode)
}
return nil
}
---
Testing Plan
1. Unit Tests
- Test multipart upload initiation
- Test part upload with presigned URLs
- Test completion with ETags
- Test abort on errors
2. Integration Tests
- Push small images (< 5MB, single part)
- Push medium images (10MB, 2 parts)
- Push large images (100MB, 20 parts)
- Test with Upcloud S3
- Test with Storj S3
3. Validation
- Monitor logs for "client disconnected" errors (should be gone)
- Check Docker push success rate
- Verify blobs stored correctly in S3
- Check bandwidth usage on hold service (should be minimal)
---
Migration & Deployment
Backward Compatibility
- Keep /put-presigned-url endpoint for fallback
- Keep /move endpoint (still needed)
- New multipart endpoints are additive
Deployment Steps
1. Update hold service with new endpoints
2. Update AppView ProxyBlobStore
3. Deploy hold service first
4. Deploy AppView
5. Test with sample push
6. Monitor logs
Rollback Plan
- Revert AppView to previous version (uses old presigned URL method)
- Hold service keeps both old and new endpoints
---
Documentation Updates
Update docs/PRESIGNED_URLS.md
- Add section "Multipart Upload for Chunked Data"
- Explain why single presigned URLs don't work with PATCH
- Document new endpoints and flow
- Add S3 part size recommendations (5MB-64MB for Storj)
Add Troubleshooting Section
- "Client disconnected during PATCH" → resolved by multipart
- Storj-specific considerations (64MB parts recommended)
- Upcloud compatibility notes
---
Performance Impact
Before (Broken)
- Docker PATCH → blocks on pipe → timeout → retry → fail
- Unable to push large images reliably
After (Multipart)
- Each PATCH → independent part upload → immediate response
- No blocking, no timeouts
- Parallel part uploads possible (future optimization)
- Reliable pushes for any image size
Bandwidth
- Hold service: Only API calls (~1KB per part)
- Direct S3 uploads: Full blob data
- S3 copy for move: Server-side (no hold bandwidth)
Estimated savings: 99.98% hold service bandwidth reduction (same as before, but now actually works!)

View File

@@ -1,448 +0,0 @@
S3 Multipart Upload Implementation Plan
Problem Summary
Current implementation uses a single presigned URL with a pipe for chunked uploads (PATCH). This causes:
- Docker PATCH requests block waiting for pipe writes
- S3 upload happens in background via single presigned URL
- Docker times out → "client disconnected during blob PATCH"
- Root cause: Single presigned URLs don't support OCI's chunked upload protocol
Solution: S3 Multipart Upload API
Implement proper S3 multipart upload to support Docker's chunked PATCH operations:
- Each PATCH → separate S3 part upload with its own presigned URL
- On Commit → complete multipart upload
- No buffering, no pipes, no blocking
---
Architecture Changes
Current (Broken) Flow
POST /blobs/uploads/ → Create() → Single presigned URL to temp location
PATCH → Write to pipe → [blocks] → Background goroutine uploads via single URL
PATCH → [blocks on pipe] → Docker timeout → disconnect ❌
New (Multipart) Flow
POST /blobs/uploads/ → Create() → Initiate multipart upload, get upload ID
PATCH #1 → Get presigned URL for part 1 → Upload part 1 to S3 → Store ETag
PATCH #2 → Get presigned URL for part 2 → Upload part 2 to S3 → Store ETag
PUT (commit) → Complete multipart upload with ETags → Done ✅
---
Implementation Details
1. Hold Service: Add Multipart Upload Endpoints
File: cmd/hold/main.go
New Request/Response Types
// StartMultipartUploadRequest initiates a multipart upload
type StartMultipartUploadRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
}
type StartMultipartUploadResponse struct {
UploadID string `json:"upload_id"`
ExpiresAt time.Time `json:"expires_at"`
}
// GetPartURLRequest requests a presigned URL for a specific part
type GetPartURLRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
PartNumber int `json:"part_number"`
}
type GetPartURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// CompleteMultipartRequest completes a multipart upload
type CompleteMultipartRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
Parts []CompletedPart `json:"parts"`
}
type CompletedPart struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
// AbortMultipartRequest aborts an in-progress upload
type AbortMultipartRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
}
New Endpoints
POST /start-multipart
func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Build S3 key from digest
// Call s3.CreateMultipartUploadRequest()
// Generate presigned URL if needed, or return upload ID
// Return upload ID to client
}
POST /part-presigned-url
func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Build S3 key from digest
// Call s3.UploadPartRequest() with part number and upload ID
// Generate presigned URL
// Return presigned URL for this specific part
}
POST /complete-multipart
func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Build S3 key from digest
// Prepare CompletedPart array with part numbers and ETags
// Call s3.CompleteMultipartUpload()
// Return success
}
POST /abort-multipart (for cleanup)
func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
// Validate DID authorization for WRITE
// Call s3.AbortMultipartUpload()
// Return success
}
S3 Implementation
// startMultipartUpload initiates a multipart upload and returns upload ID
func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) {
if s.s3Client == nil {
return "", fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
if err != nil {
return "", err
}
return *result.UploadId, nil
}
// getPartPresignedURL generates presigned URL for a specific part
func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
if s.s3Client == nil {
return "", fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
PartNumber: aws.Int64(int64(partNumber)),
})
return req.Presign(15 * time.Minute)
}
// completeMultipartUpload finalizes the multipart upload
func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
if s.s3Client == nil {
return fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
// Convert to S3 CompletedPart format
s3Parts := make([]*s3.CompletedPart, len(parts))
for i, p := range parts {
s3Parts[i] = &s3.CompletedPart{
PartNumber: aws.Int64(int64(p.PartNumber)),
ETag: aws.String(p.ETag),
}
}
_, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
MultipartUpload: &s3.CompletedMultipartUpload{
Parts: s3Parts,
},
})
return err
}
---
2. AppView: Rewrite ProxyBlobStore for Multipart
File: pkg/storage/proxy_blob_store.go
Remove Current Implementation
- Remove pipe-based streaming
- Remove background goroutine with single presigned URL
- Remove global upload tracking map
New ProxyBlobWriter Structure
type ProxyBlobWriter struct {
store *ProxyBlobStore
options distribution.CreateOptions
uploadID string // S3 multipart upload ID
parts []CompletedPart // Track uploaded parts with ETags
partNumber int // Current part number (starts at 1)
buffer *bytes.Buffer // Buffer for current part
size int64 // Total bytes written
closed bool
id string // Distribution's upload ID (for state)
startedAt time.Time
finalDigest string // Set on Commit
}
type CompletedPart struct {
PartNumber int
ETag string
}
New Create() - Initiate Multipart Upload
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
var opts distribution.CreateOptions
for _, option := range options {
if err := option.Apply(&opts); err != nil {
return nil, err
}
}
// Use temp digest for upload location
writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano())
tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", writerID))
// Start multipart upload via hold service
uploadID, err := p.startMultipartUpload(ctx, tempDigest)
if err != nil {
return nil, fmt.Errorf("failed to start multipart upload: %w", err)
}
writer := &ProxyBlobWriter{
store: p,
options: opts,
uploadID: uploadID,
parts: make([]CompletedPart, 0),
partNumber: 1,
buffer: bytes.NewBuffer(make([]byte, 0, 5*1024*1024)), // 5MB buffer
id: writerID,
startedAt: time.Now(),
}
// Store in global map for Resume()
globalUploadsMu.Lock()
globalUploads[writer.id] = writer
globalUploadsMu.Unlock()
return writer, nil
}
New Write() - Buffer and Flush Parts
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)
// Flush if buffer reaches 5MB (S3 minimum part size)
if w.buffer.Len() >= 5*1024*1024 {
if err := w.flushPart(); err != nil {
return n, err
}
}
return n, err
}
func (w *ProxyBlobWriter) flushPart() error {
if w.buffer.Len() == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Get presigned URL for this part
tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id))
url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber)
if err != nil {
return fmt.Errorf("failed to get part presigned URL: %w", err)
}
// Upload part to S3
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes()))
if err != nil {
return err
}
resp, err := w.store.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("part upload failed: status %d", resp.StatusCode)
}
// Store ETag for completion
etag := resp.Header.Get("ETag")
if etag == "" {
return fmt.Errorf("no ETag in response")
}
w.parts = append(w.parts, CompletedPart{
PartNumber: w.partNumber,
ETag: etag,
})
// Reset buffer and increment part number
w.buffer.Reset()
w.partNumber++
return nil
}
New Commit() - Complete Multipart and Move
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
// Flush any remaining buffered data
if w.buffer.Len() > 0 {
if err := w.flushPart(); err != nil {
// Try to abort multipart on error
w.store.abortMultipartUpload(ctx, w.uploadID)
return distribution.Descriptor{}, err
}
}
// Complete multipart upload at temp location
tempDigest := digest.Digest(fmt.Sprintf("uploads/temp-%s", w.id))
if err := w.store.completeMultipartUpload(ctx, tempDigest, w.uploadID, w.parts); err != nil {
return distribution.Descriptor{}, err
}
// Move from temp → final location (server-side S3 copy)
tempPath := fmt.Sprintf("uploads/temp-%s", w.id)
finalPath := desc.Digest.String()
moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s",
w.store.storageEndpoint, tempPath, finalPath, w.store.did)
req, err := http.NewRequestWithContext(ctx, "POST", moveURL, nil)
if err != nil {
return distribution.Descriptor{}, err
}
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 {
bodyBytes, _ := io.ReadAll(resp.Body)
return distribution.Descriptor{}, fmt.Errorf("move failed: %d, %s", resp.StatusCode, bodyBytes)
}
// Remove from global map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
return distribution.Descriptor{
Digest: desc.Digest,
Size: w.size,
MediaType: desc.MediaType,
}, nil
}
Add Hold Service Client Methods
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, dgst digest.Digest) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
UploadID string `json:"upload_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.UploadID, nil
}
func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, dgst digest.Digest, uploadID string, partNumber int) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"upload_id": uploadID,
"part_number": partNumber,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.URL, nil
}
func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string, parts []CompletedPart) error {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"upload_id": uploadID,
"parts": parts,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
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("complete multipart failed: status %d", resp.StatusCode)
}
return nil
}
---
Testing Plan
1. Unit Tests
- Test multipart upload initiation
- Test part upload with presigned URLs
- Test completion with ETags
- Test abort on errors
2. Integration Tests
- Push small images (< 5MB, single part)
- Push medium images (10MB, 2 parts)
- Push large images (100MB, 20 parts)
- Test with Upcloud S3
- Test with Storj S3
3. Validation
- Monitor logs for "client disconnected" errors (should be gone)
- Check Docker push success rate
- Verify blobs stored correctly in S3
- Check bandwidth usage on hold service (should be minimal)
---
Migration & Deployment
Backward Compatibility
- Keep /put-presigned-url endpoint for fallback
- Keep /move endpoint (still needed)
- New multipart endpoints are additive
Deployment Steps
1. Update hold service with new endpoints
2. Update AppView ProxyBlobStore
3. Deploy hold service first
4. Deploy AppView
5. Test with sample push
6. Monitor logs
Rollback Plan
- Revert AppView to previous version (uses old presigned URL method)
- Hold service keeps both old and new endpoints
---
Documentation Updates
Update docs/PRESIGNED_URLS.md
- Add section "Multipart Upload for Chunked Data"
- Explain why single presigned URLs don't work with PATCH
- Document new endpoints and flow
- Add S3 part size recommendations (5MB-64MB for Storj)
Add Troubleshooting Section
- "Client disconnected during PATCH" → resolved by multipart
- Storj-specific considerations (64MB parts recommended)
- Upcloud compatibility notes
---
Performance Impact
Before (Broken)
- Docker PATCH → blocks on pipe → timeout → retry → fail
- Unable to push large images reliably
After (Multipart)
- Each PATCH → independent part upload → immediate response
- No blocking, no timeouts
- Parallel part uploads possible (future optimization)
- Reliable pushes for any image size
Bandwidth
- Hold service: Only API calls (~1KB per part)
- Direct S3 uploads: Full blob data
- S3 copy for move: Server-side (no hold bandwidth)
Estimated savings: 99.98% hold service bandwidth reduction (same as before, but now actually works!)

File diff suppressed because it is too large Load Diff

View File

@@ -1,824 +0,0 @@
# S3 Presigned URLs Implementation
## Overview
Currently, ATCR's hold service acts as a proxy for all blob data, meaning every byte flows through the hold service when uploading or downloading container images. This document describes the implementation of **S3 presigned URLs** to eliminate this bottleneck, allowing direct data transfer between clients and S3-compatible storage.
### Current Architecture (Proxy Mode)
```
Downloads: Docker → AppView → Hold Service → S3 → Hold Service → AppView → Docker
Uploads: Docker → AppView → Hold Service → S3
```
**Problems:**
- All blob data flows through hold service
- Hold service bandwidth = total image bandwidth
- Latency from extra hops
- Hold service becomes bottleneck for large images
### Target Architecture (Presigned URLs)
```
Downloads: Docker → AppView (gets presigned URL) → S3 (direct download)
Uploads: Docker → AppView → S3 (via presigned URL)
Move: AppView → Hold Service → S3 (server-side CopyObject API)
```
**Benefits:**
- ✅ Hold service only orchestrates (no data transfer)
- ✅ Blob data never touches hold service
- ✅ Direct S3 uploads/downloads at wire speed
- ✅ Hold service can run on minimal resources
- ✅ Works with all S3-compatible services
## How Presigned URLs Work
### For Downloads (GET)
1. **Docker requests blob:** `GET /v2/alice/myapp/blobs/sha256:abc123`
2. **AppView asks hold service:** `POST /get-presigned-url`
```json
{"did": "did:plc:alice123", "digest": "sha256:abc123"}
```
3. **Hold service generates presigned URL:**
```go
req, _ := s3Client.GetObjectRequest(&s3.GetObjectInput{
Bucket: "my-bucket",
Key: "blobs/sha256/ab/abc123.../data",
})
url, _ := req.Presign(15 * time.Minute)
// Returns: https://gateway.storjshare.io/bucket/blobs/...?X-Amz-Signature=...
```
4. **AppView redirects Docker:** `HTTP 307 Location: <presigned-url>`
5. **Docker downloads directly from S3** using the presigned URL
**Data path:** Docker → S3 (direct)
**Hold service bandwidth:** ~1KB (API request/response)
### For Uploads (PUT)
**Small blobs (< 5MB) using Put():**
1. **Docker sends blob to AppView:** `PUT /v2/alice/myapp/blobs/uploads/{uuid}`
2. **AppView asks hold service:** `POST /put-presigned-url`
```json
{"did": "did:plc:alice123", "digest": "sha256:abc123", "size": 1024}
```
3. **Hold service generates presigned URL:**
```go
req, _ := s3Client.PutObjectRequest(&s3.PutObjectInput{
Bucket: "my-bucket",
Key: "blobs/sha256/ab/abc123.../data",
})
url, _ := req.Presign(15 * time.Minute)
```
4. **AppView uploads to S3** using presigned URL
5. **AppView confirms to Docker:** `201 Created`
**Data path:** Docker → AppView → S3 (via presigned URL)
**Hold service bandwidth:** ~1KB (API request/response)
### For Streaming Uploads (Create/Commit)
**Large blobs (> 5MB) using streaming:**
1. **Docker starts upload:** `POST /v2/alice/myapp/blobs/uploads/`
2. **AppView creates upload session** with UUID
3. **AppView gets presigned URL for temp location:**
```json
POST /put-presigned-url
{"did": "...", "digest": "uploads/temp-{uuid}", "size": 0}
```
4. **Docker streams data:** `PATCH /v2/alice/myapp/blobs/uploads/{uuid}`
5. **AppView streams to S3** using presigned URL to `uploads/temp-{uuid}/data`
6. **Docker finalizes:** `PUT /v2/.../uploads/{uuid}?digest=sha256:abc123`
7. **AppView requests move:** `POST /move?from=uploads/temp-{uuid}&to=sha256:abc123`
8. **Hold service executes S3 server-side copy:**
```go
s3.CopyObject(&s3.CopyObjectInput{
Bucket: "my-bucket",
CopySource: "/my-bucket/uploads/temp-{uuid}/data",
Key: "blobs/sha256/ab/abc123.../data",
})
s3.DeleteObject(&s3.DeleteObjectInput{
Key: "uploads/temp-{uuid}/data",
})
```
**Data path:** Docker → AppView → S3 (temp location)
**Move path:** S3 internal copy (no data transfer!)
**Hold service bandwidth:** ~2KB (presigned URL + CopyObject API)
### For Chunked Uploads (Multipart Upload)
**Large blobs with OCI chunked protocol (Docker PATCH requests):**
The OCI Distribution Spec uses chunked uploads via multiple PATCH requests. Single presigned URLs don't support this - we need **S3 Multipart Upload**.
1. **Docker starts upload:** `POST /v2/alice/myapp/blobs/uploads/`
2. **AppView initiates multipart:**
```json
POST /start-multipart
{"did": "...", "digest": "uploads/temp-{uuid}"}
→ Returns: {"upload_id": "xyz123"}
```
3. **Docker sends chunk 1:** `PATCH /v2/.../uploads/{uuid}` (5MB data)
4. **AppView gets part URL:**
```json
POST /part-presigned-url
{"did": "...", "digest": "uploads/temp-{uuid}", "upload_id": "xyz123", "part_number": 1}
→ Returns: {"url": "https://s3.../part?uploadId=xyz123&partNumber=1&..."}
```
5. **AppView uploads part 1** using presigned URL → Gets ETag
6. **Docker sends chunk 2:** `PATCH /v2/.../uploads/{uuid}` (5MB data)
7. **Repeat steps 4-5** for part 2 (and subsequent parts)
8. **Docker finalizes:** `PUT /v2/.../uploads/{uuid}?digest=sha256:abc123`
9. **AppView completes multipart:**
```json
POST /complete-multipart
{"did": "...", "digest": "uploads/temp-{uuid}", "upload_id": "xyz123",
"parts": [{"part_number": 1, "etag": "..."}, {"part_number": 2, "etag": "..."}]}
```
10. **AppView requests move:** `POST /move?from=uploads/temp-{uuid}&to=sha256:abc123`
11. **Hold service executes S3 server-side copy** (same as above)
**Data path:** Docker → AppView (buffers 5MB) → S3 (via presigned URL per part)
**Each PATCH:** Independent, non-blocking, immediate response
**Hold service bandwidth:** ~1KB per part + ~1KB for completion
**Why This Fixes "Client Disconnected" Errors:**
- Previous implementation: Single presigned URL + pipe → PATCH blocks → Docker timeout
- New implementation: Each PATCH → separate part upload → immediate response → no blocking
## Why the Temp → Final Move is Required
This is **not an ATCR implementation detail** — it's required by the [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push).
### The Problem: Unknown Digest
Docker doesn't know the blob's digest until **after** uploading:
1. **Streaming data:** Can't buffer 5GB layer in memory to calculate digest first
2. **Stdin pipes:** `docker build . | docker push` generates data on-the-fly
3. **Chunked uploads:** Multiple PATCH requests, digest calculated as data streams
### The Solution: Upload to Temp, Verify, Move
**All OCI registries do this:**
1. Client: `POST /v2/{name}/blobs/uploads/` → Get upload UUID
2. Client: `PATCH /v2/{name}/blobs/uploads/{uuid}` → Stream data to temp location
3. Client: `PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:abc` → Provide digest
4. Registry: Verify digest matches uploaded data
5. Registry: Move `uploads/{uuid}` → `blobs/sha256/abc123...`
**Docker Hub, GHCR, ECR, Harbor — all use this pattern.**
### Why It's Efficient with S3
**For S3, the move is a CopyObject API call:**
```go
// This happens INSIDE S3 servers - no data transfer!
s3.CopyObject(&s3.CopyObjectInput{
Bucket: "my-bucket",
CopySource: "/my-bucket/uploads/temp-12345/data", // 5GB blob
Key: "blobs/sha256/ab/abc123.../data",
})
// S3 copies internally, hold service only sends ~1KB API request
```
**For a 5GB layer:**
- Hold service bandwidth: **~1KB** (API request/response)
- S3 internal copy: Instant (metadata operation on S3 side)
- No data leaves S3, no network transfer
This is why the move operation is essentially free!
## Implementation Details
### 1. Add S3 Client to Hold Service
**File: `cmd/hold/main.go`**
Modify `HoldService` struct:
```go
type HoldService struct {
driver storagedriver.StorageDriver
config *Config
s3Client *s3.S3 // NEW: S3 client for presigned URLs
bucket string // NEW: Bucket name
s3PathPrefix string // NEW: Path prefix (if any)
}
```
Add initialization function:
```go
func (s *HoldService) initS3Client() error {
if s.config.Storage.Type() != "s3" {
log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type())
return nil
}
params := s.config.Storage.Parameters()["s3"].(configuration.Parameters)
// Build AWS config
awsConfig := &aws.Config{
Region: aws.String(params["region"].(string)),
Credentials: credentials.NewStaticCredentials(
params["accesskey"].(string),
params["secretkey"].(string),
"",
),
}
// Add custom endpoint for S3-compatible services (Storj, MinIO, etc.)
if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" {
awsConfig.Endpoint = aws.String(endpoint)
awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj
}
sess, err := session.NewSession(awsConfig)
if err != nil {
return fmt.Errorf("failed to create AWS session: %w", err)
}
s.s3Client = s3.New(sess)
s.bucket = params["bucket"].(string)
log.Printf("S3 presigned URLs enabled for bucket: %s", s.bucket)
return nil
}
```
Call during service initialization:
```go
func NewHoldService(cfg *Config) (*HoldService, error) {
// ... existing driver creation ...
service := &HoldService{
driver: driver,
config: cfg,
}
// Initialize S3 client for presigned URLs
if err := service.initS3Client(); err != nil {
log.Printf("WARNING: S3 presigned URLs disabled: %v", err)
}
return service, nil
}
```
### 2. Implement Presigned URL Generation
**For Downloads:**
```go
func (s *HoldService) getDownloadURL(ctx context.Context, digest string, did string) (string, error) {
path := blobPath(digest)
// Check if blob exists
if _, err := s.driver.Stat(ctx, path); err != nil {
return "", fmt.Errorf("blob not found: %w", err)
}
// If S3 client available, generate presigned URL
if s.s3Client != nil {
s3Key := strings.TrimPrefix(path, "/")
req, _ := s.s3Client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
url, err := req.Presign(15 * time.Minute)
if err != nil {
log.Printf("WARN: Presigned URL generation failed, falling back to proxy: %v", err)
return s.getProxyDownloadURL(digest, did), nil
}
log.Printf("Generated presigned download URL for %s (expires in 15min)", digest)
return url, nil
}
// Fallback: return proxy URL
return s.getProxyDownloadURL(digest, did), nil
}
func (s *HoldService) getProxyDownloadURL(digest, did string) string {
return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did)
}
```
**For Uploads:**
```go
func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) {
path := blobPath(digest)
// If S3 client available, generate presigned URL
if s.s3Client != nil {
s3Key := strings.TrimPrefix(path, "/")
req, _ := s.s3Client.PutObjectRequest(&s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
url, err := req.Presign(15 * time.Minute)
if err != nil {
log.Printf("WARN: Presigned URL generation failed, falling back to proxy: %v", err)
return s.getProxyUploadURL(digest, did), nil
}
log.Printf("Generated presigned upload URL for %s (expires in 15min)", digest)
return url, nil
}
// Fallback: return proxy URL
return s.getProxyUploadURL(digest, did), nil
}
func (s *HoldService) getProxyUploadURL(digest, did string) string {
return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did)
}
```
### 3. Multipart Upload Endpoints (Required for Chunked Uploads)
**File: `cmd/hold/main.go`**
#### Start Multipart Upload
```go
func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) {
var req StartMultipartUploadRequest // {did, digest}
// Validate DID authorization for WRITE
if !s.isAuthorizedWrite(req.DID) {
// Return 403 Forbidden
}
// Initiate S3 multipart upload
result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
// Return upload ID
json.NewEncoder(w).Encode(StartMultipartUploadResponse{
UploadID: *result.UploadId,
ExpiresAt: time.Now().Add(24 * time.Hour),
})
}
```
**Route:** `POST /start-multipart`
#### Get Part Presigned URL
```go
func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) {
var req GetPartURLRequest // {did, digest, upload_id, part_number}
// Generate presigned URL for specific part
req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
PartNumber: aws.Int64(int64(partNumber)),
})
url, err := req.Presign(15 * time.Minute)
json.NewEncoder(w).Encode(GetPartURLResponse{URL: url})
}
```
**Route:** `POST /part-presigned-url`
#### Complete Multipart Upload
```go
func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) {
var req CompleteMultipartRequest // {did, digest, upload_id, parts: [{part_number, etag}]}
// Convert parts to S3 format
s3Parts := make([]*s3.CompletedPart, len(req.Parts))
for i, p := range req.Parts {
s3Parts[i] = &s3.CompletedPart{
PartNumber: aws.Int64(int64(p.PartNumber)),
ETag: aws.String(p.ETag),
}
}
// Complete multipart upload
_, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
MultipartUpload: &s3.CompletedMultipartUpload{Parts: s3Parts},
})
}
```
**Route:** `POST /complete-multipart`
#### Abort Multipart Upload
```go
func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
var req AbortMultipartRequest // {did, digest, upload_id}
// Abort and cleanup parts
_, err := s.s3Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
})
}
```
**Route:** `POST /abort-multipart`
### 4. Move Operation (No Changes)
The existing `/move` endpoint already uses `driver.Move()`, which for S3:
- Calls `s3.CopyObject()` (server-side copy)
- Calls `s3.DeleteObject()` (delete source)
- No data transfer through hold service!
**File: `cmd/hold/main.go:393` (already exists, no changes needed)**
```go
func (s *HoldService) HandleMove(w http.ResponseWriter, r *http.Request) {
// ... existing auth and parsing ...
sourcePath := blobPath(fromPath) // uploads/temp-{uuid}/data
destPath := blobPath(toDigest) // blobs/sha256/ab/abc123.../data
// For S3, this does CopyObject + DeleteObject (server-side)
if err := s.driver.Move(ctx, sourcePath, destPath); err != nil {
// ... error handling ...
}
}
```
### 5. AppView Changes (Multipart Upload Implementation)
**File: `pkg/storage/proxy_blob_store.go:228`**
Currently streams to hold service proxy URL. Could be optimized to use presigned URL:
```go
// In Create() - line 228
go func() {
defer pipeReader.Close()
tempPath := fmt.Sprintf("uploads/temp-%s", writer.id)
// Try to get presigned URL for temp location
url, err := p.getUploadURL(ctx, digest.FromString(tempPath), 0)
if err != nil {
// Fallback to direct proxy URL
url = fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, tempPath, p.did)
}
req, err := http.NewRequestWithContext(uploadCtx, "PUT", url, pipeReader)
// ... rest unchanged
}()
```
**Note:** This optimization is optional. The presigned URL will be returned by hold service's `getUploadURL()` anyway.
## S3-Compatible Service Support
### Storj
```bash
# .env file
STORAGE_DRIVER=s3
AWS_ACCESS_KEY_ID=your-storj-access-key
AWS_SECRET_ACCESS_KEY=your-storj-secret-key
S3_BUCKET=your-bucket-name
S3_REGION=global
S3_ENDPOINT=https://gateway.storjshare.io
```
**Presigned URL example:**
```
https://gateway.storjshare.io/your-bucket/blobs/sha256/ab/abc123.../data?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...
```
### MinIO
```bash
STORAGE_DRIVER=s3
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
S3_BUCKET=registry
S3_REGION=us-east-1
S3_ENDPOINT=http://minio.example.com:9000
```
### Backblaze B2
```bash
STORAGE_DRIVER=s3
AWS_ACCESS_KEY_ID=your-b2-key-id
AWS_SECRET_ACCESS_KEY=your-b2-application-key
S3_BUCKET=your-bucket-name
S3_REGION=us-west-002
S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
```
### Cloudflare R2
```bash
STORAGE_DRIVER=s3
AWS_ACCESS_KEY_ID=your-r2-access-key-id
AWS_SECRET_ACCESS_KEY=your-r2-secret-access-key
S3_BUCKET=your-bucket-name
S3_REGION=auto
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
```
**All these services support presigned URLs with AWS SDK v1!**
## Performance Impact
### Bandwidth Savings
**Before (proxy mode):**
- 5GB layer upload: Hold service receives 5GB, sends 5GB to S3 = **10GB** bandwidth
- 5GB layer download: S3 sends 5GB to hold, hold sends 5GB to client = **10GB** bandwidth
- **Total for push+pull: 20GB hold service bandwidth**
**After (presigned URLs):**
- 5GB layer upload: Hold generates URL (1KB), AppView → S3 direct (5GB), CopyObject API (1KB) = **~2KB** hold bandwidth
- 5GB layer download: Hold generates URL (1KB), client → S3 direct = **~1KB** hold bandwidth
- **Total for push+pull: ~3KB hold service bandwidth**
**Savings: 99.98% reduction in hold service bandwidth!**
### Latency Improvements
**Before:**
- Download: Client → AppView → Hold → S3 → Hold → AppView → Client (4 hops)
- Upload: Client → AppView → Hold → S3 (3 hops)
**After:**
- Download: Client → AppView (redirect) → S3 (1 hop to data)
- Upload: Client → AppView → S3 (2 hops)
- Move: S3 internal (no network hops)
### Resource Requirements
**Before:**
- Hold service needs bandwidth = sum of all image operations
- For 100 concurrent 1GB pushes: 100GB/s bandwidth needed
- Expensive, hard to scale
**After:**
- Hold service needs minimal CPU for presigned URL signing
- For 100 concurrent 1GB pushes: ~100KB/s bandwidth needed (API traffic)
- Can run on $5/month instance!
## Security Considerations
### Presigned URL Expiration
- Default: **15 minutes** expiration
- Presigned URL includes embedded credentials in query params
- After expiry, URL becomes invalid (S3 rejects with 403)
- No long-lived URLs floating around
### Authorization Flow
1. **AppView validates user** via ATProto OAuth
2. **AppView passes DID to hold service** in presigned URL request
3. **Hold service validates DID** (owner or crew member)
4. **Hold service generates presigned URL** if authorized
5. **Client uses presigned URL** directly with S3
**Security boundary:** Hold service controls who gets presigned URLs, S3 validates the URLs.
### Fallback Security
If presigned URL generation fails:
- Falls back to proxy URLs (existing behavior)
- Still requires hold service authorization
- Data flows through hold service (original security model)
## Testing & Validation
### Verify Presigned URLs are Used
**1. Check hold service logs:**
```bash
docker logs atcr-hold | grep -i presigned
# Should see: "Generated presigned download/upload URL for sha256:..."
```
**2. Monitor network traffic:**
```bash
# Before: Large data transfers to/from hold service
docker stats atcr-hold
# After: Minimal network usage on hold service
docker stats atcr-hold
```
**3. Inspect redirect responses:**
```bash
# Should see 307 redirect to S3 URL
curl -v http://appview:5000/v2/alice/myapp/blobs/sha256:abc123 \
-H "Authorization: Bearer $TOKEN"
# Look for:
# < HTTP/1.1 307 Temporary Redirect
# < Location: https://gateway.storjshare.io/...?X-Amz-Signature=...
```
### Test Fallback Behavior
**1. With filesystem driver (should use proxy URLs):**
```bash
STORAGE_DRIVER=filesystem docker-compose up atcr-hold
# Logs should show: "Storage driver is filesystem (not S3), presigned URLs disabled"
```
**2. With S3 but invalid credentials (should fall back):**
```bash
AWS_ACCESS_KEY_ID=invalid docker-compose up atcr-hold
# Logs should show: "WARN: Presigned URL generation failed, falling back to proxy"
```
### Bandwidth Monitoring
**Track hold service bandwidth over time:**
```bash
# Install bandwidth monitoring
docker exec atcr-hold apt-get update && apt-get install -y vnstat
# Monitor
docker exec atcr-hold vnstat -l
```
**Expected results:**
- Before: Bandwidth correlates with image operations
- After: Bandwidth stays minimal regardless of image operations
## Migration Guide
### For Existing ATCR Deployments
**1. Update hold service code** (this implementation)
**2. No configuration changes needed** if already using S3:
```bash
# Existing S3 config works automatically
STORAGE_DRIVER=s3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
S3_BUCKET=...
S3_ENDPOINT=...
```
**3. Restart hold service:**
```bash
docker-compose restart atcr-hold
```
**4. Verify in logs:**
```
S3 presigned URLs enabled for bucket: my-bucket
```
**5. Test with image push/pull:**
```bash
docker push atcr.io/alice/myapp:latest
docker pull atcr.io/alice/myapp:latest
```
**6. Monitor bandwidth** to confirm reduction
### Rollback Plan
If issues arise:
**Option 1: Disable presigned URLs via env var** (if we add this feature)
```bash
PRESIGNED_URLS_ENABLED=false docker-compose restart atcr-hold
```
**Option 2: Revert code changes** to previous hold service version
The implementation has automatic fallbacks, so partial failures won't break functionality.
## Testing with DISABLE_PRESIGNED_URLS
### Environment Variable
Set `DISABLE_PRESIGNED_URLS=true` to force proxy/buffered mode even when S3 is configured.
**Use cases:**
- Testing proxy/buffered code paths with S3 storage
- Debugging multipart uploads in buffered mode
- Simulating S3 providers that don't support presigned URLs
- Verifying fallback behavior works correctly
### How It Works
When `DISABLE_PRESIGNED_URLS=true`:
**Single blob operations:**
- `getDownloadURL()` returns proxy URL instead of S3 presigned URL
- `getHeadURL()` returns proxy URL instead of S3 presigned HEAD URL
- `getUploadURL()` returns proxy URL instead of S3 presigned PUT URL
- Client uses `/blobs/{digest}` endpoints (proxy through hold service)
**Multipart uploads:**
- `StartMultipartUploadWithManager()` creates **Buffered** session instead of **S3Native**
- `GetPartUploadURL()` returns `/multipart-parts/{uploadID}/{partNumber}` instead of S3 presigned URL
- Parts are buffered in memory in the hold service
- `CompleteMultipartUploadWithManager()` assembles parts and writes via storage driver
### Testing Example
```bash
# Test S3 with forced proxy mode
export STORAGE_DRIVER=s3
export S3_BUCKET=my-bucket
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export DISABLE_PRESIGNED_URLS=true # Force buffered/proxy mode
./bin/atcr-hold
# Push an image - should use proxy mode
docker push atcr.io/yourdid/test:latest
# Check logs for:
# "Presigned URLs disabled, using proxy URL"
# "Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode"
# "Stored part: uploadID=... part=1 size=..."
```
## Future Enhancements
### 1. Configurable Expiration
Allow customizing presigned URL expiry:
```bash
PRESIGNED_URL_EXPIRY=30m # Default: 15m
```
### 2. Presigned URL Caching
Cache presigned URLs for frequently accessed blobs (with shorter TTL).
### 3. CloudFront/CDN Integration
For downloads, use CloudFront presigned URLs instead of direct S3:
- Better global distribution
- Lower egress costs
- Faster downloads
### 4. Multipart Upload Support
For very large layers (>5GB), use presigned URLs with multipart upload:
- Generate presigned URLs for each part
- Client uploads parts directly to S3
- Hold service finalizes multipart upload
### 5. Metrics & Monitoring
Track presigned URL usage:
- Count of presigned URLs generated
- Fallback rate (proxy vs presigned)
- Bandwidth savings metrics
## References
- [OCI Distribution Specification - Push](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push)
- [AWS SDK Go v1 - Presigned URLs](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/s3-example-presigned-urls.html)
- [Storj - Using Presigned URLs](https://docs.storj.io/dcs/api-reference/s3-compatible-gateway/using-presigned-urls)
- [MinIO - Presigned Upload via Browser](https://docs.min.io/community/minio-object-store/integrations/presigned-put-upload-via-browser.html)
- [Cloudflare R2 - Presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)
- [Backblaze B2 - S3 Compatible API](https://help.backblaze.com/hc/en-us/articles/360047815993-Does-the-B2-S3-Compatible-API-support-Pre-Signed-URLs)
## Summary
Implementing S3 presigned URLs transforms ATCR's hold service from a **data proxy** to a **lightweight orchestrator**:
**99.98% bandwidth reduction** for hold service
**Direct client → S3 transfers** for maximum speed
**Works with all S3-compatible services** (Storj, MinIO, R2, B2)
**OCI-compliant** temp → final move pattern
**Automatic fallback** to proxy mode for non-S3 drivers
**No breaking changes** to existing deployments
This makes BYOS (Bring Your Own Storage) truly scalable and cost-effective, as users can run hold services on minimal infrastructure while serving arbitrarily large container images.

View File

@@ -4,11 +4,11 @@
"defs": {
"main": {
"type": "record",
"description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.",
"description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.",
"key": "any",
"record": {
"type": "object",
"required": ["hold", "member", "role", "createdAt"],
"required": ["hold", "role", "createdAt"],
"properties": {
"hold": {
"type": "string",
@@ -18,7 +18,11 @@
"member": {
"type": "string",
"format": "did",
"description": "DID of the crew member who can use this hold"
"description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set."
},
"memberPattern": {
"type": "string",
"description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set."
},
"role": {
"type": "string",

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -123,7 +124,7 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
err = pdsClient.DeleteRecord(r.Context(), atproto.StarCollection, rkey)
if err != nil {
// If record doesn't exist, still return success (idempotent)
if err.Error() != "record not found" {
if !errors.Is(err, atproto.ErrRecordNotFound) {
log.Printf("UnstarRepository: Failed to delete star record: %v", err)
http.Error(w, fmt.Sprintf("Failed to delete star: %v", err), http.StatusInternalServerError)
return

View File

@@ -60,7 +60,7 @@ type RecentPushesHandler struct {
}
func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
limit := 50
limit := 20
offset := 0
if o := r.URL.Query().Get("offset"); o != "" {

View File

@@ -1,8 +1,13 @@
:root {
--primary: #0066cc;
--primary-dark: #0052a3;
--secondary: #6c757d;
--success: #28a745;
--success-bg: #d4edda;
--warning: #ffc107;
--warning-bg: #fff3cd;
--danger: #dc3545;
--danger-bg: #f8d7da;
--bg: #ffffff;
--fg: #1a1a1a;
--border-dark: #666;
@@ -10,6 +15,17 @@
--code-bg: #f5f5f5;
--hover-bg: #f9f9f9;
--star: #fbbf24;
/* Hero section colors */
--hero-bg-start: #f8f9fa;
--hero-bg-end: #e9ecef;
/* Terminal colors */
--terminal-bg: var(--fg);
--terminal-header-bg: #2d2d2d;
--terminal-text: var(--border);
--terminal-prompt: #4ec9b0;
--terminal-comment: #6a9955;
}
* {
@@ -694,23 +710,26 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
padding: 1rem;
}
/* Status Messages */
/* Status Messages / Callouts */
.note {
background: var(--warning-bg);
border-left: 4px solid var(--warning);
padding: 1rem;
margin: 1rem 0;
}
.success {
color: var(--success);
padding: 0.5rem;
background: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 4px;
margin-top: 1rem;
background: var(--success-bg);
border-left: 4px solid var(--success);
padding: 1rem;
margin: 1rem 0;
}
.error {
color: var(--danger);
padding: 0.5rem;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-top: 1rem;
background: var(--danger-bg);
border-left: 4px solid var(--danger);
padding: 1rem;
margin: 1rem 0;
}
/* Load More Button */
@@ -1167,6 +1186,248 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
color: var(--fg);
}
/* Hero Section */
.hero-section {
background: linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-end) 100%);
padding: 4rem 2rem;
border-bottom: 1px solid var(--border);
}
.hero-content {
max-width: 900px;
margin: 0 auto;
text-align: center;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--fg);
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.2rem;
color: var(--border-dark);
margin-bottom: 3rem;
line-height: 1.6;
}
.hero-terminal {
max-width: 600px;
margin: 0 auto 2.5rem;
background: var(--terminal-bg);
border-radius: 8px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.terminal-header {
background: var(--terminal-header-bg);
padding: 0.75rem 1rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.terminal-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--border-dark);
}
.terminal-dot:nth-child(1) {
background: #ff5f56;
}
.terminal-dot:nth-child(2) {
background: #ffbd2e;
}
.terminal-dot:nth-child(3) {
background: #27c93f;
}
.terminal-content {
padding: 1.5rem;
margin: 0;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.8;
color: var(--terminal-text);
overflow-x: auto;
}
.terminal-prompt {
color: var(--terminal-prompt);
font-weight: bold;
}
.terminal-comment {
color: var(--terminal-comment);
font-style: italic;
}
.hero-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 4rem;
}
.btn-hero-primary,
.btn-hero-secondary {
padding: 0.9rem 2rem;
font-size: 1.1rem;
font-weight: 600;
border-radius: 6px;
text-decoration: none;
transition: all 0.2s ease;
display: inline-block;
}
.btn-hero-primary {
background: var(--primary);
color: var(--bg);
border: 2px solid var(--primary);
}
.btn-hero-primary:hover {
background: var(--primary-dark);
border-color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3);
}
.btn-hero-secondary {
background: transparent;
color: var(--primary);
border: 2px solid var(--primary);
}
.btn-hero-secondary:hover {
background: var(--primary);
color: var(--bg);
transform: translateY(-2px);
}
.hero-benefits {
max-width: 1000px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
.benefit-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem 1.5rem;
text-align: center;
transition: all 0.2s ease;
}
.benefit-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-4px);
}
.benefit-icon {
font-size: 3rem;
margin-bottom: 1rem;
line-height: 1;
}
.benefit-card h3 {
font-size: 1.2rem;
margin-bottom: 0.75rem;
color: var(--fg);
}
.benefit-card p {
color: var(--border-dark);
font-size: 0.95rem;
line-height: 1.5;
margin: 0;
}
/* Install Page */
.install-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.install-section {
margin: 2rem 0;
}
.install-section h2 {
margin-bottom: 1rem;
color: var(--fg);
}
.install-section h3 {
margin: 1.5rem 0 0.5rem;
color: var(--border-dark);
font-size: 1.1rem;
}
.code-block {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1rem;
margin: 0.5rem 0 1rem;
overflow-x: auto;
}
.code-block code {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
.platform-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid var(--border);
margin-bottom: 1rem;
}
.platform-tab {
padding: 0.5rem 1rem;
cursor: pointer;
border: none;
background: none;
font-size: 1rem;
color: var(--border-dark);
transition: all 0.2s;
}
.platform-tab:hover {
color: var(--fg);
}
.platform-tab.active {
color: var(--primary);
border-bottom: 2px solid var(--primary);
margin-bottom: -2px;
}
.platform-content {
display: none;
}
.platform-content.active {
display: block;
}
/* Responsive */
@media (max-width: 768px) {
.navbar {
@@ -1219,10 +1480,52 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
.featured-card {
min-height: auto;
}
.hero-section {
padding: 3rem 1.5rem;
}
.hero-title {
font-size: 2rem;
}
.hero-subtitle {
font-size: 1rem;
margin-bottom: 2rem;
}
.hero-terminal {
margin-bottom: 2rem;
}
.terminal-content {
font-size: 0.85rem;
padding: 1rem;
}
.hero-actions {
flex-direction: column;
margin-bottom: 3rem;
}
.btn-hero-primary,
.btn-hero-secondary {
width: 100%;
text-align: center;
}
.hero-benefits {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
@media (max-width: 1024px) and (min-width: 769px) {
.featured-grid {
grid-template-columns: repeat(2, 1fr);
}
.hero-benefits {
grid-template-columns: repeat(3, 1fr);
}
}

View File

@@ -4,9 +4,11 @@
$ErrorActionPreference = "Stop"
# Configuration
$Repo = "atcr-io/atcr"
$BinaryName = "docker-credential-atcr.exe"
$InstallDir = if ($env:ATCR_INSTALL_DIR) { $env:ATCR_INSTALL_DIR } else { "$env:ProgramFiles\ATCR" }
$Version = "v0.0.1"
$TagHash = "c6cfbaf1723123907f9d23e300f6f72081e65006"
$TangledRepo = "https://tangled.org/@evan.jarrett.net/at-container-registry"
Write-Host "ATCR Credential Helper Installer for Windows" -ForegroundColor Green
Write-Host ""
@@ -27,23 +29,14 @@ function Get-Architecture {
$Arch = Get-Architecture
Write-Host "Detected: Windows $Arch" -ForegroundColor Green
# Get latest release version
function Get-LatestVersion {
Write-Host "Fetching latest version..." -ForegroundColor Yellow
$releaseUrl = "https://api.github.com/repos/$Repo/releases/latest"
try {
$release = Invoke-RestMethod -Uri $releaseUrl -UseBasicParsing
return $release.tag_name
} catch {
Write-Host "Failed to fetch latest version: $_" -ForegroundColor Red
exit 1
}
if ($env:ATCR_VERSION) {
$Version = $env:ATCR_VERSION
Write-Host "Using specified version: $Version" -ForegroundColor Yellow
} else {
Write-Host "Using version: $Version" -ForegroundColor Green
}
$Version = if ($env:ATCR_VERSION) { $env:ATCR_VERSION } else { Get-LatestVersion }
Write-Host "Latest version: $Version" -ForegroundColor Green
# Download and install binary
function Install-Binary {
param (
@@ -53,7 +46,7 @@ function Install-Binary {
$versionClean = $Version.TrimStart('v')
$fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.zip"
$downloadUrl = "https://github.com/$Repo/releases/download/$Version/$fileName"
$downloadUrl = "$TangledRepo/tags/$TagHash/download/$fileName"
Write-Host "Downloading from: $downloadUrl" -ForegroundColor Yellow
@@ -133,10 +126,7 @@ function Show-Configuration {
Write-Host ""
Write-Host "Installation complete!" -ForegroundColor Green
Write-Host ""
Write-Host "To use ATCR with Docker, configure your credentials:" -ForegroundColor Yellow
Write-Host " docker-credential-atcr configure"
Write-Host ""
Write-Host "Then configure Docker to use this credential helper:" -ForegroundColor Yellow
Write-Host "To use ATCR with Docker, configure Docker to use this credential helper:" -ForegroundColor Yellow
Write-Host ' Edit %USERPROFILE%\.docker\config.json and add:'
Write-Host ' {
"credHelpers": {

View File

@@ -11,9 +11,11 @@ YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Configuration
REPO="atcr-io/atcr"
BINARY_NAME="docker-credential-atcr"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
VERSION="v0.0.1"
TAG_HASH="c6cfbaf1723123907f9d23e300f6f72081e65006"
TANGLED_REPO="https://tangled.org/@evan.jarrett.net/at-container-registry"
# Detect OS and architecture
detect_platform() {
@@ -47,23 +49,11 @@ detect_platform() {
esac
}
# Get latest release version
get_latest_version() {
echo -e "${YELLOW}Fetching latest version...${NC}"
LATEST_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
echo -e "${RED}Failed to fetch latest version${NC}"
exit 1
fi
echo -e "${GREEN}Latest version: ${LATEST_VERSION}${NC}"
}
# Download and install binary
install_binary() {
local version="${1:-$LATEST_VERSION}"
local download_url="https://github.com/${REPO}/releases/download/${version}/docker-credential-atcr_${version#v}_${OS}_${ARCH}.tar.gz"
local version="${1:-$VERSION}"
local download_url="${TANGLED_REPO}/tags/${TAG_HASH}/download/docker-credential-atcr_${version#v}_${OS}_${ARCH}.tar.gz"
echo -e "${YELLOW}Downloading from: ${download_url}${NC}"
@@ -111,10 +101,7 @@ configure_docker() {
echo ""
echo -e "${GREEN}Installation complete!${NC}"
echo ""
echo -e "${YELLOW}To use ATCR with Docker, configure your credentials:${NC}"
echo -e " ${BINARY_NAME} configure"
echo ""
echo -e "${YELLOW}Then configure Docker to use this credential helper:${NC}"
echo -e "${YELLOW}To use ATCR with Docker, configure Docker to use this credential helper:${NC}"
echo -e ' echo '\''{"credHelpers": {"atcr.io": "atcr"}}'\'' > ~/.docker/config.json'
echo ""
echo -e "${YELLOW}Or add to existing config.json:${NC}"
@@ -135,10 +122,10 @@ main() {
# Allow specifying version via environment variable
if [ -z "$ATCR_VERSION" ]; then
get_latest_version
echo -e "Using version: ${GREEN}${VERSION}${NC}"
else
LATEST_VERSION="$ATCR_VERSION"
echo -e "Using specified version: ${GREEN}${LATEST_VERSION}${NC}"
VERSION="$ATCR_VERSION"
echo -e "Using specified version: ${GREEN}${VERSION}${NC}"
fi
install_binary

View File

@@ -11,6 +11,55 @@
<body>
{{ template "nav" . }}
{{ if not .User }}
<!-- Hero Section for Non-Logged-In Users -->
<section class="hero-section">
<div class="hero-content">
<h1 class="hero-title">ship containers on the open web.</h1>
<p class="hero-subtitle">
Push and pull Docker images on the AT Protocol.<br>
Browse public registries or control your data.
</p>
<div class="hero-terminal">
<div class="terminal-header">
<span class="terminal-dot"></span>
<span class="terminal-dot"></span>
<span class="terminal-dot"></span>
</div>
<pre class="terminal-content"><span class="terminal-prompt">$</span> docker login atcr.io
<span class="terminal-prompt">$</span> docker push atcr.io/you/app
<span class="terminal-comment"># same docker, decentralized</span></pre>
</div>
<div class="hero-actions">
<a href="/auth/oauth/login?return_to=/" class="btn-hero-primary">Get Started</a>
<a href="/install" class="btn-hero-secondary">Learn More</a>
</div>
</div>
<!-- Benefit Cards -->
<div class="hero-benefits">
<div class="benefit-card">
<div class="benefit-icon">🐳</div>
<h3>Works with Docker</h3>
<p>Use docker push & pull. No new tools to learn.</p>
</div>
<div class="benefit-card">
<div class="benefit-icon"></div>
<h3>Your Data</h3>
<p>Join shared holds or captain your own storage.</p>
</div>
<div class="benefit-card">
<div class="benefit-icon">🧭</div>
<h3>Discover Images</h3>
<p>Browse and star public container registries.</p>
</div>
</div>
</section>
{{ end }}
<main class="container">
<div class="home-page">
<!-- Featured Repositories Section -->

View File

@@ -7,79 +7,6 @@
<title>Install ATCR Credential Helper - ATCR</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
.install-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.install-section {
margin: 2rem 0;
}
.install-section h2 {
margin-bottom: 1rem;
color: #1a1a1a;
}
.install-section h3 {
margin: 1.5rem 0 0.5rem;
color: #4a4a4a;
font-size: 1.1rem;
}
.code-block {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1rem;
margin: 0.5rem 0 1rem;
overflow-x: auto;
}
.code-block code {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.9rem;
line-height: 1.5;
}
.platform-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid #e0e0e0;
margin-bottom: 1rem;
}
.platform-tab {
padding: 0.5rem 1rem;
cursor: pointer;
border: none;
background: none;
font-size: 1rem;
color: #666;
transition: all 0.2s;
}
.platform-tab:hover {
color: #000;
}
.platform-tab.active {
color: #0066cc;
border-bottom: 2px solid #0066cc;
margin-bottom: -2px;
}
.platform-content {
display: none;
}
.platform-content.active {
display: block;
}
.note {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 1rem;
margin: 1rem 0;
}
.success {
background: #d4edda;
border-left: 4px solid #28a745;
padding: 1rem;
margin: 1rem 0;
}
</style>
</head>
<body>
{{ template "nav" . }}
@@ -137,8 +64,7 @@ chmod +x install.sh
<h2>Authentication</h2>
<p>The credential helper will automatically prompt for authentication when you push or pull:</p>
<div class="code-block"><code>export ATCR_AUTO_AUTH=1
docker push {{ .RegistryURL }}/yourhandle/myapp:latest</code></div>
<div class="code-block"><code>docker push {{ .RegistryURL }}/yourhandle/myapp:latest</code></div>
<p>This will:</p>
<ol>
@@ -180,12 +106,8 @@ which docker-credential-atcr
# Add to PATH if needed
export PATH="/usr/local/bin:$PATH"</code></div>
<h3>Authentication failed</h3>
<p>Make sure auto-auth is enabled:</p>
<div class="code-block"><code>export ATCR_AUTO_AUTH=1</code></div>
<h3>Still having issues?</h3>
<p>Check the <a href="https://github.com/atcr-io/atcr/blob/main/INSTALLATION.md">full documentation</a> or <a href="https://github.com/atcr-io/atcr/issues">open an issue</a>.</p>
<p>Check the <a href="https://tangled.org/@evan.jarrett.net/at-container-registry/blob/main/INSTALLATION.md">full documentation</a> or <a href="https://tangled.org/@evan.jarrett.net/at-container-registry/issues">open an issue</a>.</p>
</div>
<div class="install-section">

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -13,6 +14,11 @@ import (
"github.com/bluesky-social/indigo/atproto/client"
)
// Sentinel errors
var (
ErrRecordNotFound = errors.New("record not found")
)
// Client wraps ATProto operations for the registry
type Client struct {
pdsEndpoint string
@@ -118,8 +124,12 @@ func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Recor
var result Record
err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
if err != nil {
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
return nil, fmt.Errorf("record not found")
// Check for RecordNotFound error from indigo's APIError type
var apiErr *client.APIError
if errors.As(err, &apiErr) {
if apiErr.StatusCode == 404 || apiErr.Name == "RecordNotFound" {
return nil, ErrRecordNotFound
}
}
return nil, fmt.Errorf("getRecord failed: %w", err)
}
@@ -148,12 +158,17 @@ func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Recor
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("record not found")
return nil, ErrRecordNotFound
}
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))
bodyStr := string(bodyBytes)
// Check for RecordNotFound error (PDS returns 400 with this error)
if strings.Contains(bodyStr, "RecordNotFound") {
return nil, ErrRecordNotFound
}
return nil, fmt.Errorf("get record failed with status %d: %s", resp.StatusCode, bodyStr)
}
var result Record

View File

@@ -205,6 +205,7 @@ func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord {
// 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
// Supports both explicit DIDs (with backlinks) and pattern-based matching (wildcards, handle globs)
type HoldCrewRecord struct {
// Type should be "io.atcr.hold.crew"
Type string `json:"$type"`
@@ -213,27 +214,47 @@ type HoldCrewRecord struct {
// 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"`
// Member is the DID of the crew member (optional, for explicit access)
// Exactly one of Member or MemberPattern must be set
Member *string `json:"member,omitempty"`
// MemberPattern is a pattern for matching multiple users (optional, for pattern-based access)
// Supports wildcards: "*" (all users), "*.domain.com" (handle glob)
// Exactly one of Member or MemberPattern must be set
MemberPattern *string `json:"memberPattern,omitempty"`
// Role defines permissions: "owner", "write", "read"
Role string `json:"role"`
// ExpiresAt is optional expiration for this membership
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
// AddedAt timestamp
AddedAt time.Time `json:"createdAt"`
}
// NewHoldCrewRecord creates a new hold crew record
// NewHoldCrewRecord creates a new hold crew record with explicit DID
func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord {
return &HoldCrewRecord{
Type: HoldCrewCollection,
Hold: hold,
Member: member,
Member: &member,
Role: role,
AddedAt: time.Now(),
}
}
// NewHoldCrewRecordWithPattern creates a new hold crew record with pattern matching
func NewHoldCrewRecordWithPattern(hold, pattern, role string) *HoldCrewRecord {
return &HoldCrewRecord{
Type: HoldCrewCollection,
Hold: hold,
MemberPattern: &pattern,
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 {

View File

@@ -3,6 +3,7 @@ package atproto
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
@@ -47,7 +48,7 @@ func (s *ManifestStore) Exists(ctx context.Context, dgst digest.Digest) (bool, e
_, err := s.client.GetRecord(ctx, ManifestCollection, rkey)
if err != nil {
// If not found, return false without error
if err.Error() == "record not found" {
if errors.Is(err, ErrRecordNotFound) {
return false, nil
}
return false, err

View File

@@ -3,6 +3,7 @@ package atproto
import (
"context"
"encoding/json"
"errors"
"fmt"
)
@@ -63,28 +64,7 @@ func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRe
return nil
}
// isNotFoundError checks if an error is a 404 not found error
// isNotFoundError checks if an error is a record 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
return errors.Is(err, ErrRecordNotFound)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
@@ -79,6 +80,7 @@ func (s *HoldService) isHoldPublic() (bool, error) {
}
// isCrewMember checks if a DID is a crew member of this hold
// Supports both explicit DID matching and pattern-based matching (wildcards, handle globs)
func (s *HoldService) isCrewMember(did string) (bool, error) {
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
@@ -104,6 +106,17 @@ func (s *HoldService) isCrewMember(did string) (bool, error) {
return false, fmt.Errorf("no PDS endpoint found for owner")
}
// Build this hold's URI for filtering
publicURL := s.config.Server.PublicURL
if publicURL == "" {
return false, fmt.Errorf("hold public URL not configured")
}
holdName, err := extractHostname(publicURL)
if err != nil {
return false, fmt.Errorf("failed to extract hold name: %w", err)
}
holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
// Create unauthenticated client to read public records
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
@@ -114,17 +127,54 @@ func (s *HoldService) isCrewMember(did string) (bool, error) {
return false, fmt.Errorf("failed to list crew records: %w", err)
}
// Check if DID is in crew list
// Resolve handle once for pattern matching (lazily, only if needed)
var handle string
var handleResolved bool
// Check crew records for both explicit DID and pattern matches
for _, record := range records {
var crewRecord atproto.HoldCrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
continue
}
if crewRecord.Member == did {
// Found crew membership
// Only check crew records for THIS hold (prevents cross-hold access)
if crewRecord.Hold != holdURI {
continue
}
// Check expiration (if set)
if crewRecord.ExpiresAt != nil && time.Now().After(*crewRecord.ExpiresAt) {
continue // Skip expired membership
}
// Check explicit DID match
if crewRecord.Member != nil && *crewRecord.Member == did {
// Found explicit crew membership
return true, nil
}
// Check pattern match (if pattern is set)
if crewRecord.MemberPattern != nil && *crewRecord.MemberPattern != "" {
// Lazy handle resolution - only resolve if we encounter a pattern
if !handleResolved {
handle, err = resolveHandle(did)
if err != nil {
log.Printf("Warning: failed to resolve handle for DID %s: %v", did, err)
// Continue checking explicit DIDs even if handle resolution fails
handleResolved = true // Mark as attempted (don't retry)
handle = "" // Empty handle won't match patterns
} else {
handleResolved = true
}
}
// If we have a handle, check pattern match
if handle != "" && matchPattern(*crewRecord.MemberPattern, handle) {
// Found pattern-based crew membership
return true, nil
}
}
}
return false, nil

View File

@@ -21,6 +21,11 @@ type RegistrationConfig struct {
// OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER)
// If set, auto-registration is enabled
OwnerDID string `yaml:"owner_did"`
// AllowAllCrew controls whether to create a wildcard crew record (from env: HOLD_ALLOW_ALL_CREW)
// If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users)
// If false, deletes the wildcard crew record if it exists
AllowAllCrew bool `yaml:"allow_all_crew"`
}
// StorageConfig wraps distribution's storage configuration
@@ -72,6 +77,7 @@ func LoadConfigFromEnv() (*Config, error) {
// Registration configuration (optional)
cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true"
// Storage configuration - build from env vars based on storage type
storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")

40
pkg/hold/patterns.go Normal file
View File

@@ -0,0 +1,40 @@
package hold
import (
"regexp"
"strings"
)
// matchPattern checks if a handle matches a pattern
// Supports wildcards: "*" (all), "*.domain.com" (suffix), "prefix.*" (prefix), "*.mid.*" (contains)
func matchPattern(pattern, handle string) bool {
if pattern == "*" {
// Wildcard matches all
return true
}
// Convert glob to regex and match
regex := globToRegex(pattern)
matched, err := regexp.MatchString(regex, handle)
if err != nil {
// Log error but fail closed (don't grant access on regex error)
return false
}
return matched
}
// globToRegex converts a glob pattern to a regex pattern
// Examples:
// - "*.example.com" → "^.*\.example\.com$"
// - "subdomain.*" → "^subdomain\..*$"
// - "*.bsky.*" → "^.*\.bsky\..*$"
func globToRegex(pattern string) string {
// Escape special regex characters (except *)
escaped := regexp.QuoteMeta(pattern)
// Replace escaped \* with .*
regex := strings.ReplaceAll(escaped, "\\*", ".*")
// Anchor to start and end
return "^" + regex + "$"
}

View File

@@ -3,6 +3,7 @@ package hold
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -115,62 +116,8 @@ func (s *HoldService) AutoRegister(callbackHandler *http.HandlerFunc) error {
// registerWithOAuth performs OAuth flow and registers the hold
func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error {
// Define the scopes we need for hold registration
holdScopes := []string{
"atproto",
fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection),
fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection),
}
// Determine base URL based on mode
// Callback path standardized to /auth/oauth/callback across ATCR
var baseURL string
if s.config.Server.TestMode {
// Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
// Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080")
parsedURL, err := url.Parse(publicURL)
if err != nil {
return fmt.Errorf("failed to parse public URL: %w", err)
}
port := parsedURL.Port()
if port == "" {
port = "8080" // default
}
baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
} else {
baseURL = publicURL
}
// Run interactive OAuth flow with persistent server
ctx := context.Background()
result, err := oauth.InteractiveFlowWithCallback(
ctx,
baseURL,
handle,
holdScopes, // Pass hold-specific scopes
func(handler http.HandlerFunc) error {
// Populate the pre-registered callback handler
*callbackHandler = handler
return nil
},
func(authURL string) error {
// Display OAuth URL for user to visit
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH AUTHORIZATION REQUIRED")
log.Print(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.Print(strings.Repeat("=", 80) + "\n")
return nil
},
)
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Hold service registration")
if err != nil {
return err
}
@@ -180,10 +127,6 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri
log.Printf("DID: %s", did)
log.Printf("PDS: %s", pdsEndpoint)
// Create ATProto client with indigo's API client (handles DPoP automatically)
apiClient := result.Session.APIClient()
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
return s.registerWithClient(publicURL, did, client)
}
@@ -265,3 +208,274 @@ func extractHostname(urlStr string) (string, error) {
}
return hostname, nil
}
// ReconcileAllowAllCrew reconciles the allow-all crew record state with the environment variable
// Called on every startup to ensure the PDS record matches the desired configuration
func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error {
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
// No owner DID configured, skip reconciliation
return nil
}
desiredState := s.config.Registration.AllowAllCrew
log.Printf("Checking allow-all crew state (desired: %v)", desiredState)
// Query PDS for current state
actualState, err := s.hasAllowAllCrewRecord()
if err != nil {
return fmt.Errorf("failed to check allow-all crew record: %w", err)
}
log.Printf("Allow-all crew record exists: %v", actualState)
// States match - nothing to do
if desiredState == actualState {
if desiredState {
log.Printf("✓ Allow-all crew enabled (all authenticated users can push)")
} else {
log.Printf("✓ Allow-all crew disabled (explicit crew membership required)")
}
return nil
}
// State mismatch - need to reconcile
if desiredState && !actualState {
// Need to create wildcard crew record
log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)")
return s.createAllowAllCrewRecord(callbackHandler)
}
if !desiredState && actualState {
// Need to delete wildcard crew record
log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW=false)")
return s.deleteAllowAllCrewRecord(callbackHandler)
}
return nil
}
// hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS for THIS hold
func (s *HoldService) hasAllowAllCrewRecord() (bool, error) {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
if ownerDID == "" {
return false, fmt.Errorf("hold owner DID not configured")
}
if publicURL == "" {
return false, fmt.Errorf("hold public URL not configured")
}
ctx := context.Background()
// Resolve owner's PDS endpoint
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return false, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return false, fmt.Errorf("no PDS endpoint found for owner")
}
// Build hold-specific rkey
holdName, err := extractHostname(publicURL)
if err != nil {
return false, fmt.Errorf("failed to extract hostname: %w", err)
}
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
// Create unauthenticated client to read public records
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
// Query for hold-specific allow-all record
record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, crewRKey)
if err != nil {
// Record doesn't exist
if errors.Is(err, atproto.ErrRecordNotFound) {
return false, nil
}
return false, fmt.Errorf("failed to get crew record: %w", err)
}
// Verify it's the wildcard record (memberPattern: "*")
var crewRecord atproto.HoldCrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
return false, fmt.Errorf("failed to unmarshal crew record: %w", err)
}
// Check if it's the exact wildcard pattern
if crewRecord.MemberPattern == nil || *crewRecord.MemberPattern != "*" {
return false, nil
}
// Verify it's for this hold (defensive check)
expectedHoldURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
return crewRecord.Hold == expectedHoldURI, nil
}
// createAllowAllCrewRecord creates a wildcard crew record allowing all authenticated users
func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Creating allow-all crew record")
if err != nil {
return err
}
ctx := context.Background()
// Get hold URI
holdName, err := extractHostname(publicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname: %w", err)
}
holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
// Create wildcard crew record
crewRecord := atproto.NewHoldCrewRecordWithPattern(holdURI, "*", "write")
// Use hold-specific rkey to support multiple holds with different allow-all settings
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
_, err = client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
return fmt.Errorf("failed to create allow-all crew record: %w", err)
}
log.Printf("✓ Created allow-all crew record (allows all authenticated users)")
return nil
}
// deleteAllowAllCrewRecord deletes the wildcard crew record for this hold
func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
// Safety check: only delete if it's the exact wildcard pattern for THIS hold
isWildcard, err := s.hasAllowAllCrewRecord()
if err != nil {
return fmt.Errorf("failed to check allow-all crew record: %w", err)
}
if !isWildcard {
log.Printf("Note: 'allow-all' crew record not found for this hold (may exist for other holds)")
return nil
}
// Get hold name for rkey
holdName, err := extractHostname(s.config.Server.PublicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname: %w", err)
}
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Deleting allow-all crew record")
if err != nil {
return err
}
ctx := context.Background()
// Delete the hold-specific allow-all record
err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, crewRKey)
if err != nil {
return fmt.Errorf("failed to delete allow-all crew record: %w", err)
}
log.Printf("✓ Deleted allow-all crew record for this hold")
return nil
}
// getHoldRegistrationScopes returns the OAuth scopes needed for hold registration and crew management
func getHoldRegistrationScopes() []string {
return []string{
"atproto",
fmt.Sprintf("repo:%s", atproto.HoldCollection),
fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
}
}
// runOAuthFlow performs OAuth flow and returns an authenticated client
// Reusable helper to avoid code duplication across registration and reconciliation
func (s *HoldService) runOAuthFlow(callbackHandler *http.HandlerFunc, purpose string) (*atproto.Client, error) {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
ctx := context.Background()
// Resolve owner's PDS endpoint
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return nil, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return nil, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return nil, fmt.Errorf("no PDS endpoint found for owner")
}
handle := ident.Handle.String()
if handle == "" || handle == "handle.invalid" {
return nil, fmt.Errorf("no valid handle found for DID")
}
// Determine base URL for OAuth
var baseURL string
if s.config.Server.TestMode {
parsedURL, err := url.Parse(publicURL)
if err != nil {
return nil, fmt.Errorf("failed to parse public URL: %w", err)
}
port := parsedURL.Port()
if port == "" {
port = "8080"
}
baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
} else {
baseURL = publicURL
}
// Run OAuth flow
result, err := oauth.InteractiveFlowWithCallback(
ctx,
baseURL,
handle,
getHoldRegistrationScopes(),
func(handler http.HandlerFunc) error {
*callbackHandler = handler
return nil
},
func(authURL string) error {
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH REQUIRED: %s", purpose)
log.Print(strings.Repeat("=", 80))
log.Printf("\nVisit: %s\n", authURL)
log.Printf("Waiting for authorization...")
log.Print(strings.Repeat("=", 80) + "\n")
return nil
},
)
if err != nil {
return nil, fmt.Errorf("OAuth flow failed: %w", err)
}
// Create authenticated client
apiClient := result.Session.APIClient()
return atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient), nil
}

88
pkg/hold/resolve.go Normal file
View File

@@ -0,0 +1,88 @@
package hold
import (
"context"
"fmt"
"sync"
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// handleCache provides caching for DID → handle resolution
// This reduces latency for pattern matching authorization checks
type handleCache struct {
mu sync.RWMutex
cache map[string]cacheEntry // did → handle
}
type cacheEntry struct {
handle string
expiresAt time.Time
}
const handleCacheTTL = 10 * time.Minute
var (
// Global handle cache instance
globalHandleCache = &handleCache{
cache: make(map[string]cacheEntry),
}
)
// get retrieves a cached handle for a DID
func (c *handleCache) get(did string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.cache[did]
if !ok || time.Now().After(entry.expiresAt) {
return "", false
}
return entry.handle, true
}
// set stores a handle in the cache
func (c *handleCache) set(did, handle string) {
c.mu.Lock()
defer c.mu.Unlock()
c.cache[did] = cacheEntry{
handle: handle,
expiresAt: time.Now().Add(handleCacheTTL),
}
}
// resolveHandle resolves a DID to its current handle using ATProto identity resolution
// Results are cached for 10 minutes to reduce latency
func resolveHandle(did string) (string, error) {
// Check cache first
if handle, ok := globalHandleCache.get(did); ok {
return handle, nil
}
// Cache miss - resolve from network
ctx := context.Background()
directory := identity.DefaultDirectory()
didParsed, err := syntax.ParseDID(did)
if err != nil {
return "", fmt.Errorf("invalid DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return "", fmt.Errorf("failed to resolve DID: %w", err)
}
handle := ident.Handle.String()
if handle == "" || handle == "handle.invalid" {
return "", fmt.Errorf("no valid handle found for DID")
}
// Cache the result
globalHandleCache.set(did, handle)
return handle, nil
}

View File

@@ -15,4 +15,6 @@ ARTIFACT_JSON=$(echo "$BLOB_OUTPUT" | jq --arg tag "$TAG_BYTES" --arg name "$ART
}') &&
echo "$ARTIFACT_JSON" > temp_artifact.json &&
cat temp_artifact.json &&
goat record create temp_artifact.json -n
goat record create temp_artifact.json -n
rm temp_artifact.json
sleep 2