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

Configure Feed

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

use presigned urls for s3 to avoid hold bandwidth

+186 -60
+6
.env.hold.example
··· 16 16 17 17 # Storage driver type (s3, filesystem) 18 18 # Default: s3 19 + # 20 + # S3 Presigned URLs: 21 + # When using S3 storage, presigned URLs are automatically enabled for direct 22 + # client ↔ S3 transfers. This eliminates the hold service as a bandwidth 23 + # bottleneck, reducing hold bandwidth by ~99% for push/pull operations. 24 + # Falls back to proxy mode automatically for non-S3 drivers. 19 25 STORAGE_DRIVER=filesystem 20 26 21 27 # For S3/Storj/Minio:
+10 -31
Dockerfile.appview
··· 1 - # ========================================== 2 - # Stage 1: Build stage with Debian (glibc) 3 - # ========================================== 4 1 FROM golang:1.25.2-trixie AS builder 5 2 6 - # Install SQLite development libraries (for CGO compilation) 7 3 RUN apt-get update && \ 8 4 apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \ 9 5 rm -rf /var/lib/apt/lists/* 10 6 11 - # Set working directory 12 7 WORKDIR /build 13 8 14 - # Copy go mod files and download dependencies (cached layer) 15 9 COPY go.mod go.sum ./ 16 10 RUN go mod download 17 11 18 - # Copy source code 19 12 COPY . . 20 13 21 - # Build optimized binary: 22 - # - CGO_ENABLED=1: Required for SQLite (mattn/go-sqlite3) 23 - # - -ldflags="-s -w": Strip debug symbols (~30% size reduction) 24 - # - -tags sqlite_omit_load_extension: Remove SQLite extension loading (~100KB savings) 25 - # - -trimpath: Remove build paths (reproducible builds) 26 - # SQLite is statically embedded in the binary (no runtime .so needed) 27 14 RUN CGO_ENABLED=1 go build \ 28 15 -ldflags="-s -w" \ 29 16 -tags sqlite_omit_load_extension \ 30 17 -trimpath \ 31 18 -o atcr-appview ./cmd/appview 32 19 33 - # Collect minimal runtime dependencies based on ldd output 34 - RUN mkdir -p /runtime-deps/lib/x86_64-linux-gnu /runtime-deps/lib64 && \ 35 - # Core glibc library (only one the binary links to) 36 - cp -L /lib/x86_64-linux-gnu/libc.so.6 /runtime-deps/lib/x86_64-linux-gnu/ && \ 37 - # Dynamic linker 38 - cp -L /lib64/ld-linux-x86-64.so.2 /runtime-deps/lib64/ && \ 39 - # NSS modules for DNS resolution (loaded via dlopen at runtime, not shown in ldd) 40 - cp -L /lib/x86_64-linux-gnu/libnss_dns.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 41 - cp -L /lib/x86_64-linux-gnu/libnss_files.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 42 - # NSS modules depend on libresolv 43 - cp -L /lib/x86_64-linux-gnu/libresolv.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 20 + # Collect minimal runtime dependencies 21 + RUN mkdir -p /runtime-deps/lib64 /runtime-deps/lib/x86_64-linux-gnu && \ 22 + # Core glibc libraries (from ldd output) 23 + cp /lib/x86_64-linux-gnu/libc.so.6 /runtime-deps/lib64/ && \ 24 + cp /lib/x86_64-linux-gnu/libresolv.so.2 /runtime-deps/lib64/ && \ 25 + cp /lib64/ld-linux-x86-64.so.2 /runtime-deps/lib64/ && \ 26 + # NSS (Name Service Switch) modules for DNS resolution 27 + cp /lib/x86_64-linux-gnu/libnss_dns.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 28 + cp /lib/x86_64-linux-gnu/libnss_files.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 44 29 # Create NSS config (tells glibc to check /etc/hosts then DNS) 45 30 echo "hosts: files dns" > /tmp/nsswitch.conf 46 31 ··· 51 36 52 37 # Copy minimal glibc runtime dependencies 53 38 COPY --from=builder /runtime-deps / 54 - 55 39 # Copy NSS configuration for DNS resolution 56 40 COPY --from=builder /tmp/nsswitch.conf /etc/nsswitch.conf 57 - 58 41 # Copy CA certificates for HTTPS (PDS, Jetstream, relay connections) 59 42 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 60 - 61 43 # Copy timezone data for timestamp formatting 62 44 COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 63 - 64 45 # Copy optimized binary (SQLite embedded) 65 46 COPY --from=builder /build/atcr-appview /atcr-appview 66 47 67 - # Expose port (main HTTP server) 48 + # Expose ports 68 49 EXPOSE 5000 69 50 70 51 # OCI image annotations ··· 77 58 org.opencontainers.image.version="0.1.0" \ 78 59 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" 79 60 80 - # Run the AppView (no config file - uses environment variables) 81 - # Creates /var/lib/atcr directories on first run via Go code 82 61 ENTRYPOINT ["/atcr-appview"] 83 62 CMD ["serve"]
+8 -8
cmd/appview/config.go
··· 97 97 storage["maintenance"] = configuration.Parameters{ 98 98 "uploadpurging": map[interface{}]interface{}{ 99 99 "enabled": false, 100 - "age": 7 * 24 * time.Hour, // 168h 101 - "interval": 24 * time.Hour, // 24h 100 + "age": 7 * 24 * time.Hour, // 168h 101 + "interval": 24 * time.Hour, // 24h 102 102 "dryrun": false, 103 103 }, 104 104 } ··· 141 141 142 142 return configuration.Auth{ 143 143 "token": configuration.Parameters{ 144 - "realm": realm, 145 - "service": serviceName, 146 - "issuer": serviceName, 147 - "rootcertbundle": certPath, 148 - "privatekey": privateKeyPath, 149 - "expiration": expiration, 144 + "realm": realm, 145 + "service": serviceName, 146 + "issuer": serviceName, 147 + "rootcertbundle": certPath, 148 + "privatekey": privateKeyPath, 149 + "expiration": expiration, 150 150 }, 151 151 }, nil 152 152 }
+5 -5
cmd/appview/serve.go
··· 33 33 34 34 // Define sensitive tables that should never be accessible from public queries 35 35 var sensitiveTables = map[string]bool{ 36 - "oauth_sessions": true, // OAuth tokens 37 - "ui_sessions": true, // Session IDs 38 - "oauth_auth_requests": true, // OAuth state 39 - "devices": true, // Device secret hashes 40 - "pending_device_auth": true, // Pending device secrets 36 + "oauth_sessions": true, // OAuth tokens 37 + "ui_sessions": true, // Session IDs 38 + "oauth_auth_requests": true, // OAuth state 39 + "devices": true, // Device secret hashes 40 + "pending_device_auth": true, // Pending device secrets 41 41 } 42 42 43 43 // readOnlyAuthorizerCallback blocks access to sensitive tables
+151 -10
cmd/hold/main.go
··· 12 12 "strings" 13 13 "time" 14 14 15 + "github.com/aws/aws-sdk-go/aws" 16 + "github.com/aws/aws-sdk-go/aws/credentials" 17 + "github.com/aws/aws-sdk-go/aws/session" 18 + "github.com/aws/aws-sdk-go/service/s3" 15 19 "github.com/distribution/distribution/v3/configuration" 16 20 storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 17 21 "github.com/distribution/distribution/v3/registry/storage/driver/factory" ··· 70 74 71 75 // HoldService provides presigned URLs for blob storage in a hold 72 76 type HoldService struct { 73 - driver storagedriver.StorageDriver 74 - config *Config 77 + driver storagedriver.StorageDriver 78 + config *Config 79 + s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage) 80 + bucket string // S3 bucket name 81 + s3PathPrefix string // S3 path prefix (if any) 75 82 } 76 83 77 84 // NewHoldService creates a new hold service ··· 83 90 return nil, fmt.Errorf("failed to create storage driver: %w", err) 84 91 } 85 92 86 - return &HoldService{ 93 + service := &HoldService{ 87 94 driver: driver, 88 95 config: cfg, 89 - }, nil 96 + } 97 + 98 + // Initialize S3 client for presigned URLs (if using S3 storage) 99 + if err := service.initS3Client(); err != nil { 100 + log.Printf("WARNING: S3 presigned URLs disabled: %v", err) 101 + } 102 + 103 + return service, nil 104 + } 105 + 106 + // initS3Client initializes the S3 client for presigned URL generation 107 + // Returns nil error if S3 client is successfully initialized 108 + // Returns error if storage is not S3 or if initialization fails (service will fall back to proxy mode) 109 + func (s *HoldService) initS3Client() error { 110 + // Check if storage driver is S3 111 + if s.config.Storage.Type() != "s3" { 112 + log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type()) 113 + return nil // Not an error - just using different driver 114 + } 115 + 116 + // Extract S3 configuration from storage parameters 117 + params, ok := s.config.Storage.Parameters()["s3"].(configuration.Parameters) 118 + if !ok { 119 + return fmt.Errorf("failed to get S3 parameters from storage config") 120 + } 121 + 122 + // Extract required S3 configuration 123 + region, _ := params["region"].(string) 124 + if region == "" { 125 + region = "us-east-1" // Default region 126 + } 127 + 128 + accessKey, _ := params["accesskey"].(string) 129 + secretKey, _ := params["secretkey"].(string) 130 + bucket, _ := params["bucket"].(string) 131 + 132 + if bucket == "" { 133 + return fmt.Errorf("S3 bucket not configured") 134 + } 135 + 136 + // Build AWS config 137 + awsConfig := &aws.Config{ 138 + Region: aws.String(region), 139 + } 140 + 141 + // Add credentials if provided (allow IAM role auth if not provided) 142 + if accessKey != "" && secretKey != "" { 143 + awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "") 144 + } 145 + 146 + // Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.) 147 + if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" { 148 + awsConfig.Endpoint = aws.String(endpoint) 149 + awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj 150 + } 151 + 152 + // Create AWS session 153 + sess, err := session.NewSession(awsConfig) 154 + if err != nil { 155 + return fmt.Errorf("failed to create AWS session: %w", err) 156 + } 157 + 158 + // Create S3 client 159 + s.s3Client = s3.New(sess) 160 + s.bucket = bucket 161 + 162 + // Extract path prefix if configured (rootdirectory in S3 params) 163 + if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" { 164 + s.s3PathPrefix = strings.TrimPrefix(rootDir, "/") 165 + } 166 + 167 + log.Printf("S3 presigned URLs enabled for bucket: %s", s.bucket) 168 + if s.s3PathPrefix != "" { 169 + log.Printf("S3 path prefix: %s", s.s3PathPrefix) 170 + } 171 + 172 + return nil 90 173 } 91 174 92 175 // GetPresignedURLRequest represents a request for a presigned download URL ··· 491 574 return "", fmt.Errorf("blob not found: %w", err) 492 575 } 493 576 494 - // For drivers that support presigned URLs (S3), use those 495 - // For now, return a proxy URL through this service with DID for authorization 496 - return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil 577 + // If S3 client available, generate presigned URL 578 + if s.s3Client != nil { 579 + // Build S3 key from blob path 580 + // blobPath returns paths like: /docker/registry/v2/blobs/sha256/ab/abc123.../data 581 + s3Key := strings.TrimPrefix(path, "/") 582 + if s.s3PathPrefix != "" { 583 + s3Key = s.s3PathPrefix + "/" + s3Key 584 + } 585 + 586 + // Generate presigned GET URL 587 + req, _ := s.s3Client.GetObjectRequest(&s3.GetObjectInput{ 588 + Bucket: aws.String(s.bucket), 589 + Key: aws.String(s3Key), 590 + }) 591 + 592 + url, err := req.Presign(15 * time.Minute) 593 + if err != nil { 594 + log.Printf("WARN: Presigned URL generation failed for %s, falling back to proxy: %v", digest, err) 595 + return s.getProxyDownloadURL(digest, did), nil 596 + } 597 + 598 + log.Printf("Generated presigned download URL for %s (expires in 15min)", digest) 599 + return url, nil 600 + } 601 + 602 + // Fallback: return proxy URL through this service 603 + return s.getProxyDownloadURL(digest, did), nil 604 + } 605 + 606 + // getProxyDownloadURL returns a proxy URL for blob download (fallback when presigned URLs unavailable) 607 + func (s *HoldService) getProxyDownloadURL(digest, did string) string { 608 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 497 609 } 498 610 499 611 // getUploadURL generates an upload URL for a blob 500 612 // Note: This is called from HandlePutPresignedURL which has the DID in the request 501 613 func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) { 502 - // For drivers that support presigned URLs (S3), use those 503 - // For now, return a proxy URL through this service with DID for authorization 504 - return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil 614 + // If S3 client available, generate presigned URL 615 + if s.s3Client != nil { 616 + // Build S3 key from blob path 617 + path := blobPath(digest) 618 + s3Key := strings.TrimPrefix(path, "/") 619 + if s.s3PathPrefix != "" { 620 + s3Key = s.s3PathPrefix + "/" + s3Key 621 + } 622 + 623 + // Generate presigned PUT URL 624 + req, _ := s.s3Client.PutObjectRequest(&s3.PutObjectInput{ 625 + Bucket: aws.String(s.bucket), 626 + Key: aws.String(s3Key), 627 + }) 628 + 629 + url, err := req.Presign(15 * time.Minute) 630 + if err != nil { 631 + log.Printf("WARN: Presigned URL generation failed for %s, falling back to proxy: %v", digest, err) 632 + return s.getProxyUploadURL(digest, did), nil 633 + } 634 + 635 + log.Printf("Generated presigned upload URL for %s (expires in 15min)", digest) 636 + return url, nil 637 + } 638 + 639 + // Fallback: return proxy URL through this service 640 + return s.getProxyUploadURL(digest, did), nil 641 + } 642 + 643 + // getProxyUploadURL returns a proxy URL for blob upload (fallback when presigned URLs unavailable) 644 + func (s *HoldService) getProxyUploadURL(digest, did string) string { 645 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 505 646 } 506 647 507 648 // RegisterRequest represents a request to register this hold in a user's PDS
+1 -1
go.mod
··· 3 3 go 1.24.7 4 4 5 5 require ( 6 + github.com/aws/aws-sdk-go v1.55.5 6 7 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 7 8 github.com/distribution/distribution/v3 v3.0.0 8 9 github.com/distribution/reference v0.6.0 ··· 19 20 ) 20 21 21 22 require ( 22 - github.com/aws/aws-sdk-go v1.55.5 // indirect 23 23 github.com/beorn7/perks v1.0.1 // indirect 24 24 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect 25 25 github.com/carlmjohnson/versioninfo v0.22.5 // indirect
+4 -4
pkg/appview/db/schema.go
··· 196 196 197 197 // Migration represents a database migration 198 198 type Migration struct { 199 - Version int 200 - Name string 201 - Description string `yaml:"description"` 202 - Query string `yaml:"query"` 199 + Version int 200 + Name string 201 + Description string `yaml:"description"` 202 + Query string `yaml:"query"` 203 203 } 204 204 205 205 // runMigrations applies any pending database migrations
+1 -1
pkg/appview/jetstream/worker.go
··· 46 46 pongMutex sync.Mutex 47 47 48 48 // In-memory cursor tracking for reconnects 49 - lastCursor int64 49 + lastCursor int64 50 50 cursorMutex sync.RWMutex 51 51 } 52 52