A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
fork

Configure Feed

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

fix docker push command

+534 -127
+3 -3
.env.example
··· 16 16 17 17 # Storage driver type (s3, filesystem) 18 18 # Default: s3 19 - STORAGE_DRIVER=s3 19 + STORAGE_DRIVER=filesystem 20 20 21 21 # For S3/Storj/Minio: 22 22 AWS_ACCESS_KEY_ID=your_access_key ··· 50 50 # Your ATProto DID (REQUIRED for registration) 51 51 # Get your DID: https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social 52 52 # 53 - # On first run with HOLD_CREW_OWNER set: 53 + # On first run with HOLD_OWNER set: 54 54 # 1. Hold service will print an OAuth URL to the logs 55 55 # 2. Visit the URL in your browser to authorize 56 56 # 3. Hold service creates hold + crew records in your PDS ··· 60 60 # - Hold service checks if already registered 61 61 # - Skips OAuth if records exist 62 62 # 63 - HOLD_CREW_OWNER=did:plc:your-did-here 63 + HOLD_OWNER=did:plc:your-did-here
+4 -4
CLAUDE.md
··· 40 40 export HOLD_PUBLIC_URL=http://127.0.0.1:8080 41 41 export STORAGE_DRIVER=filesystem 42 42 export STORAGE_ROOT_DIR=/tmp/atcr-hold 43 - export HOLD_CREW_OWNER=did:plc:your-did-here 43 + export HOLD_OWNER=did:plc:your-did-here 44 44 ./atcr-hold 45 45 # Check logs for OAuth URL, visit in browser to complete registration 46 46 ``` ··· 299 299 - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials 300 300 - `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration 301 301 - `HOLD_PUBLIC` - Allow public reads (default: false) 302 - - `HOLD_CREW_OWNER` - DID for auto-registration (optional) 302 + - `HOLD_OWNER` - DID for auto-registration (optional) 303 303 304 304 **Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc. 305 305 ··· 386 386 - Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*` 387 387 - Authorization: Based on PDS records (`hold.public`, crew records) 388 388 - Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC` 389 - - Auto-registration: `HOLD_CREW_OWNER` (optional) 389 + - Auto-registration: `HOLD_OWNER` (optional) 390 390 391 391 **Credential Helper**: 392 392 - Token storage: `~/.atcr/oauth-token.json` ··· 439 439 440 440 **Adding BYOS support for a user**: 441 441 1. User sets environment variables (storage credentials, public URL) 442 - 2. User runs hold service with `HOLD_CREW_OWNER` set - auto-registration via OAuth 442 + 2. User runs hold service with `HOLD_OWNER` set - auto-registration via OAuth 443 443 3. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS 444 444 4. AppView automatically queries PDS and routes blobs to user's storage 445 445 5. No AppView changes needed - fully decentralized
+1 -1
SPEC.md
··· 431 431 432 432 Unified Model 433 433 434 - Every hold service requires HOLD_CREW_OWNER: 434 + Every hold service requires HOLD_OWNER: 435 435 - Owner's PDS has the io.atcr.hold record 436 436 - Owner's PDS has all io.atcr.hold.crew records 437 437 - Authorization is always governed by PDS records
+225 -91
cmd/hold/main.go
··· 34 34 35 35 // RegistrationConfig defines auto-registration settings 36 36 type RegistrationConfig struct { 37 - // OwnerDID is the owner's ATProto DID (from env: HOLD_CREW_OWNER) 37 + // OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER) 38 38 // If set, auto-registration is enabled 39 39 OwnerDID string `yaml:"owner_did"` 40 40 } ··· 55 55 // Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC) 56 56 Public bool `yaml:"public"` 57 57 58 + // TestMode uses localhost for OAuth redirects while storing real URL in hold record (from env: TEST_MODE) 59 + TestMode bool `yaml:"test_mode"` 60 + 58 61 // ReadTimeout for HTTP requests 59 62 ReadTimeout time.Duration `yaml:"read_timeout"` 60 63 ··· 64 67 65 68 // HoldService provides presigned URLs for blob storage in a hold 66 69 type HoldService struct { 67 - driver storagedriver.StorageDriver 68 - config *Config 70 + driver storagedriver.StorageDriver 71 + config *Config 72 + oauthCodeCh chan string 73 + oauthErrCh chan error 74 + oauthState string 75 + codeVerifier string 69 76 } 70 77 71 78 // NewHoldService creates a new hold service ··· 78 85 } 79 86 80 87 return &HoldService{ 81 - driver: driver, 82 - config: cfg, 88 + driver: driver, 89 + config: cfg, 90 + oauthCodeCh: make(chan string, 1), 91 + oauthErrCh: make(chan error, 1), 83 92 }, nil 84 93 } 85 94 ··· 183 192 ctx := context.Background() 184 193 expiry := time.Now().Add(15 * time.Minute) 185 194 186 - url, err := s.getUploadURL(ctx, req.Digest, req.Size) 195 + url, err := s.getUploadURL(ctx, req.Digest, req.Size, req.DID) 187 196 if err != nil { 188 197 http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) 189 198 return ··· 200 209 201 210 // HandleProxyGet proxies a blob download through the service 202 211 func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) { 203 - if r.Method != http.MethodGet { 212 + if r.Method != http.MethodGet && r.Method != http.MethodHead { 204 213 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 205 214 return 206 215 } ··· 228 237 return 229 238 } 230 239 231 - // Read blob from storage 232 240 ctx := r.Context() 233 - path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 241 + path := blobPath(digest) 234 242 243 + // For HEAD requests, just check if blob exists 244 + if r.Method == http.MethodHead { 245 + stat, err := s.driver.Stat(ctx, path) 246 + if err != nil { 247 + http.Error(w, "blob not found", http.StatusNotFound) 248 + return 249 + } 250 + w.Header().Set("Content-Type", "application/octet-stream") 251 + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) 252 + w.WriteHeader(http.StatusOK) 253 + return 254 + } 255 + 256 + // For GET requests, read and return the blob 235 257 content, err := s.driver.GetContent(ctx, path) 236 258 if err != nil { 237 259 http.Error(w, "blob not found", http.StatusNotFound) ··· 244 266 245 267 // HandleProxyPut proxies a blob upload through the service 246 268 func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) { 269 + log.Printf("HandleProxyPut: method=%s, path=%s, query=%s", r.Method, r.URL.Path, r.URL.RawQuery) 270 + 247 271 if r.Method != http.MethodPut { 248 272 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 249 273 return ··· 260 284 did = r.Header.Get("X-ATCR-DID") 261 285 } 262 286 287 + log.Printf("HandleProxyPut: digest=%s, did=%s", digest, did) 288 + 263 289 // Authorize WRITE access 264 - if !s.isAuthorizedWrite(did) { 290 + authorized := s.isAuthorizedWrite(did) 291 + log.Printf("HandleProxyPut: authorization check: did=%s, authorized=%v", did, authorized) 292 + if !authorized { 265 293 if did == "" { 294 + log.Printf("HandleProxyPut: rejecting - no DID provided") 266 295 http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) 267 296 } else { 297 + log.Printf("HandleProxyPut: rejecting - DID not authorized for write") 268 298 http.Error(w, "forbidden: write access denied", http.StatusForbidden) 269 299 } 270 300 return ··· 272 302 273 303 // Write blob to storage 274 304 ctx := r.Context() 275 - path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 305 + path := blobPath(digest) 276 306 277 307 content, err := io.ReadAll(r.Body) 278 308 if err != nil { 309 + log.Printf("HandleProxyPut: failed to read body: %v", err) 279 310 http.Error(w, "failed to read body", http.StatusBadRequest) 280 311 return 281 312 } 282 313 314 + log.Printf("HandleProxyPut: writing blob to path=%s, size=%d bytes", path, len(content)) 283 315 if err := s.driver.PutContent(ctx, path, content); err != nil { 316 + log.Printf("HandleProxyPut: failed to store blob: %v", err) 284 317 http.Error(w, "failed to store blob", http.StatusInternalServerError) 285 318 return 286 319 } 287 320 321 + log.Printf("HandleProxyPut: successfully stored blob digest=%s, size=%d", digest, len(content)) 288 322 w.WriteHeader(http.StatusCreated) 289 323 } 290 324 ··· 400 434 // getDownloadURL generates a download URL for a blob 401 435 func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) { 402 436 // Check if blob exists 403 - path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 437 + path := blobPath(digest) 404 438 _, err := s.driver.Stat(ctx, path) 405 439 if err != nil { 406 440 return "", fmt.Errorf("blob not found: %w", err) ··· 408 442 409 443 // For drivers that support presigned URLs (S3), use those 410 444 // For now, return a proxy URL through this service 411 - return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil 445 + return fmt.Sprintf("%s/blobs/%s", s.config.Server.PublicURL, digest), nil 412 446 } 413 447 414 448 // getUploadURL generates an upload URL for a blob 415 - func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64) (string, error) { 449 + // Note: This is called from HandlePutPresignedURL which has the DID in the request 450 + func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) { 416 451 // For drivers that support presigned URLs (S3), use those 417 - // For now, return a proxy URL through this service 418 - return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil 452 + // For now, return a proxy URL through this service with DID for authorization 453 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil 419 454 } 420 455 421 456 // RegisterRequest represents a request to register this hold in a user's PDS ··· 515 550 }) 516 551 } 517 552 553 + // HandleOAuthCallback handles OAuth callback from authorization server 554 + func (s *HoldService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { 555 + code := r.URL.Query().Get("code") 556 + receivedState := r.URL.Query().Get("state") 557 + 558 + if receivedState != s.oauthState { 559 + s.oauthErrCh <- fmt.Errorf("invalid state parameter") 560 + http.Error(w, "Invalid state", http.StatusBadRequest) 561 + return 562 + } 563 + 564 + if code == "" { 565 + s.oauthErrCh <- fmt.Errorf("no authorization code received") 566 + http.Error(w, "No code", http.StatusBadRequest) 567 + return 568 + } 569 + 570 + w.Header().Set("Content-Type", "text/html") 571 + fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`) 572 + 573 + // Send code to registration flow 574 + select { 575 + case s.oauthCodeCh <- code: 576 + default: 577 + // Channel already has a value or nobody is listening 578 + } 579 + } 580 + 518 581 func main() { 519 582 // Load configuration from environment variables 520 583 cfg, err := loadConfigFromEnv() ··· 528 591 log.Fatalf("Failed to create hold service: %v", err) 529 592 } 530 593 531 - // Auto-register if owner DID is set 532 - if cfg.Registration.OwnerDID != "" { 533 - if err := service.AutoRegister(); err != nil { 534 - log.Printf("WARNING: Auto-registration failed: %v", err) 535 - log.Printf("You can register manually later using the /register endpoint") 536 - } else { 537 - log.Printf("Successfully registered hold service in PDS") 538 - } 539 - } 540 - 541 594 // Setup HTTP routes 542 595 mux := http.NewServeMux() 543 596 mux.HandleFunc("/health", service.HealthHandler) 544 597 mux.HandleFunc("/register", service.HandleRegister) 545 598 mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL) 546 599 mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL) 600 + mux.HandleFunc("/oauth/callback", service.HandleOAuthCallback) // OAuth callback on same port 601 + 602 + // OAuth client metadata endpoint for ATProto OAuth 603 + clientID := cfg.Server.PublicURL + "/client-metadata.json" 604 + clientMetadata := oauth.NewClientMetadata(clientID, []string{cfg.Server.PublicURL + "/oauth/callback"}) 605 + clientMetadata.ClientName = "ATCR Hold Service" 606 + clientMetadata.ApplicationType = "web" // Changed from "native" since this is a web service 607 + mux.HandleFunc("/client-metadata.json", oauth.ServeMetadata(clientMetadata)) 547 608 mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) { 548 - if r.Method == http.MethodGet { 609 + if r.Method == http.MethodGet || r.Method == http.MethodHead { 549 610 service.HandleProxyGet(w, r) 550 611 } else if r.Method == http.MethodPut { 551 612 service.HandleProxyPut(w, r) ··· 562 623 WriteTimeout: cfg.Server.WriteTimeout, 563 624 } 564 625 565 - log.Printf("Starting hold service on %s", cfg.Server.Addr) 566 - if err := server.ListenAndServe(); err != nil { 626 + // Start server in goroutine so we can do auto-registration after it's running 627 + serverErr := make(chan error, 1) 628 + go func() { 629 + log.Printf("Starting hold service on %s", cfg.Server.Addr) 630 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 631 + serverErr <- err 632 + } 633 + }() 634 + 635 + // Give server a moment to start 636 + time.Sleep(100 * time.Millisecond) 637 + 638 + // Auto-register if owner DID is set (now that server is running) 639 + if cfg.Registration.OwnerDID != "" { 640 + if err := service.AutoRegister(); err != nil { 641 + log.Printf("WARNING: Auto-registration failed: %v", err) 642 + log.Printf("You can register manually later using the /register endpoint") 643 + } else { 644 + log.Printf("Successfully registered hold service in PDS") 645 + } 646 + } 647 + 648 + // Wait for server error or shutdown 649 + if err := <-serverErr; err != nil { 567 650 log.Fatalf("Server failed: %v", err) 568 651 } 569 652 } ··· 581 664 return nil, fmt.Errorf("HOLD_PUBLIC_URL is required") 582 665 } 583 666 cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" 584 - cfg.Server.ReadTimeout = 30 * time.Second 585 - cfg.Server.WriteTimeout = 30 * time.Second 667 + cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 668 + cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads 669 + cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads 586 670 587 671 // Registration configuration (optional) 588 - cfg.Registration.OwnerDID = os.Getenv("HOLD_CREW_OWNER") 672 + cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER") 589 673 590 674 // Storage configuration - build from env vars based on storage type 591 675 storageType := getEnvOrDefault("STORAGE_DRIVER", "s3") ··· 647 731 return defaultValue 648 732 } 649 733 734 + // blobPath converts a digest (e.g., "sha256:abc123...") to a storage path 735 + // Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data 736 + // where xx is the first 2 characters of the hash for directory sharding 737 + // NOTE: Path must start with / for filesystem driver 738 + func blobPath(digest string) string { 739 + // Split digest into algorithm and hash 740 + parts := strings.SplitN(digest, ":", 2) 741 + if len(parts) != 2 { 742 + // Fallback for malformed digest 743 + return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) 744 + } 745 + 746 + algorithm := parts[0] 747 + hash := parts[1] 748 + 749 + // Use first 2 characters for sharding 750 + if len(hash) < 2 { 751 + return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash) 752 + } 753 + 754 + return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash) 755 + } 756 + 650 757 // isHoldRegistered checks if a hold with the given public URL is already registered in the PDS 651 758 func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) { 652 759 // We need to query the PDS without authentication to check public records ··· 685 792 } 686 793 687 794 if reg.OwnerDID == "" { 688 - return fmt.Errorf("HOLD_CREW_OWNER not set - required for registration") 795 + return fmt.Errorf("HOLD_OWNER not set - required for registration") 689 796 } 690 797 691 798 ctx := context.Background() ··· 730 837 731 838 // registerWithOAuth performs OAuth flow and registers the hold 732 839 func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error { 733 - // Use 127.0.0.1 for localhost callback (works better than "localhost") 734 - callbackAddr := "127.0.0.1:8888" 735 - redirectURI := fmt.Sprintf("http://%s/callback", callbackAddr) 840 + // Extract port from publicURL for test mode 841 + var redirectURI string 842 + var clientID string 843 + 844 + // Define the scopes we need for hold registration 845 + // Need create and update permissions for hold and crew collections 846 + scopes := fmt.Sprintf("atproto repo:%s?action=create repo:%s?action=update repo:%s?action=create repo:%s?action=update", 847 + atproto.HoldCollection, atproto.HoldCollection, 848 + atproto.HoldCrewCollection, atproto.HoldCrewCollection) 849 + 850 + if s.config.Server.TestMode { 851 + // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record 852 + // Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080") 853 + parsedURL, err := url.Parse(publicURL) 854 + if err != nil { 855 + return fmt.Errorf("failed to parse public URL: %w", err) 856 + } 857 + port := parsedURL.Port() 858 + if port == "" { 859 + port = "8080" // default 860 + } 861 + redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port) 862 + clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", 863 + url.QueryEscape(redirectURI), url.QueryEscape(scopes)) 864 + } else if strings.Contains(publicURL, "127.0.0.1") || strings.Contains(publicURL, "localhost") { 865 + // Localhost development mode per ATProto OAuth spec 866 + redirectURI = publicURL + "/oauth/callback" 867 + clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", 868 + url.QueryEscape(redirectURI), url.QueryEscape(scopes)) 869 + } else { 870 + // Production mode - use client metadata URL 871 + redirectURI = publicURL + "/oauth/callback" 872 + clientID = publicURL + "/client-metadata.json" 873 + } 736 874 737 875 // Create OAuth client 738 - oauthClient, err := oauth.NewClient("http://hold-service", redirectURI) 876 + oauthClient, err := oauth.NewClient(clientID, redirectURI) 739 877 if err != nil { 740 878 return fmt.Errorf("failed to create OAuth client: %w", err) 741 879 } ··· 746 884 return fmt.Errorf("failed to initialize OAuth: %w", err) 747 885 } 748 886 887 + // Set the scopes we need for hold registration (create and update) 888 + oauthClient.SetScopes([]string{ 889 + "atproto", 890 + fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection), 891 + fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection), 892 + fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection), 893 + fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), 894 + }) 895 + 749 896 // Generate authorization URL 750 - state := "hold-registration" 751 - authURL, codeVerifier, err := oauthClient.AuthorizeURL(state) 897 + s.oauthState = "hold-registration" 898 + authURL, codeVerifier, err := oauthClient.AuthorizeURL(s.oauthState) 752 899 if err != nil { 753 900 return fmt.Errorf("failed to generate auth URL: %w", err) 754 901 } 902 + s.codeVerifier = codeVerifier 755 903 756 904 // Print the OAuth URL for user to visit 757 905 log.Print("\n" + strings.Repeat("=", 80)) ··· 762 910 log.Printf("Waiting for authorization...") 763 911 log.Print(strings.Repeat("=", 80) + "\n") 764 912 765 - // Start temporary HTTP server for callback 766 - codeChan := make(chan string, 1) 767 - errChan := make(chan error, 1) 768 - 769 - mux := http.NewServeMux() 770 - mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 771 - code := r.URL.Query().Get("code") 772 - receivedState := r.URL.Query().Get("state") 773 - 774 - if receivedState != state { 775 - errChan <- fmt.Errorf("invalid state parameter") 776 - http.Error(w, "Invalid state", http.StatusBadRequest) 777 - return 778 - } 779 - 780 - if code == "" { 781 - errChan <- fmt.Errorf("no authorization code received") 782 - http.Error(w, "No code", http.StatusBadRequest) 783 - return 784 - } 785 - 786 - w.Header().Set("Content-Type", "text/html") 787 - fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`) 788 - codeChan <- code 789 - }) 790 - 791 - server := &http.Server{ 792 - Addr: callbackAddr, 793 - Handler: mux, 794 - } 795 - 796 - go func() { 797 - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 798 - errChan <- err 799 - } 800 - }() 801 - 802 - // Wait for callback or error 913 + // Wait for callback or error (callback happens on main server) 803 914 var code string 804 915 select { 805 - case code = <-codeChan: 806 - // Got the code, shutdown callback server 807 - server.Shutdown(context.Background()) 808 - case err := <-errChan: 809 - server.Shutdown(context.Background()) 916 + case code = <-s.oauthCodeCh: 917 + // Got the code from callback 918 + case err := <-s.oauthErrCh: 810 919 return err 811 920 case <-time.After(5 * time.Minute): 812 - server.Shutdown(context.Background()) 813 921 return fmt.Errorf("OAuth timeout - no response after 5 minutes") 814 922 } 815 923 816 924 log.Printf("Authorization received, exchanging code for token...") 817 925 818 926 // Exchange code for token 819 - token, err := oauthClient.Exchange(ctx, code, codeVerifier) 927 + token, err := oauthClient.Exchange(ctx, code, s.codeVerifier) 820 928 if err != nil { 821 929 return fmt.Errorf("failed to exchange code: %w", err) 822 930 } ··· 825 933 log.Printf("DID: %s", did) 826 934 log.Printf("PDS: %s", pdsEndpoint) 827 935 828 - // Now register with the token 829 - return s.registerWithToken(publicURL, did, pdsEndpoint, token.AccessToken) 936 + // Now register with the token using DPoP 937 + // Create ATProto client with DPoP transport from OAuth client 938 + dpopKey := oauthClient.DPoPKey() 939 + dpopTransport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) 940 + // Set the access token in the transport for "ath" claim computation 941 + dpopTransport.SetAccessToken(token.AccessToken) 942 + client := atproto.NewClientWithDPoP(pdsEndpoint, did, token.AccessToken, dpopKey, dpopTransport) 943 + 944 + return s.registerWithClient(publicURL, did, client) 830 945 } 831 946 832 - // registerWithToken registers the hold using an access token 833 - func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken string) error { 947 + // registerWithClient registers the hold using an authenticated ATProto client 948 + func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error { 834 949 // Derive hold name from URL (hostname) 835 950 holdName, err := extractHostname(publicURL) 836 951 if err != nil { ··· 840 955 log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did) 841 956 842 957 ctx := context.Background() 843 - 844 - // Create ATProto client with owner's credentials 845 - client := atproto.NewClient(pdsEndpoint, did, accessToken) 846 958 847 959 // Create HoldRecord 848 960 holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public) ··· 865 977 } 866 978 867 979 log.Printf("✓ Created crew record: %s", crewResult.URI) 980 + 981 + // Update sailor profile to set this as the default hold 982 + profile, err := atproto.GetProfile(ctx, client) 983 + if err != nil { 984 + log.Printf("Warning: failed to get sailor profile: %v", err) 985 + } else { 986 + if profile == nil { 987 + // Create new profile with this hold as default 988 + profile = atproto.NewSailorProfileRecord(publicURL) 989 + } else { 990 + // Update existing profile with new defaultHold 991 + profile.DefaultHold = publicURL 992 + profile.UpdatedAt = time.Now() 993 + } 994 + 995 + err = atproto.UpdateProfile(ctx, client, profile) 996 + if err != nil { 997 + log.Printf("Warning: failed to update sailor profile: %v", err) 998 + } else { 999 + log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL) 1000 + } 1001 + } 868 1002 869 1003 log.Print("\n" + strings.Repeat("=", 80)) 870 1004 log.Printf("REGISTRATION COMPLETE")
+122
cmd/profile-update/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/base64" 6 + "encoding/json" 7 + "flag" 8 + "fmt" 9 + "log" 10 + "os" 11 + "path/filepath" 12 + "strings" 13 + 14 + atprotoAuth "atcr.io/pkg/auth/atproto" 15 + "atcr.io/pkg/atproto" 16 + ) 17 + 18 + // DockerConfig represents ~/.docker/config.json 19 + type DockerConfig struct { 20 + Auths map[string]AuthEntry `json:"auths"` 21 + } 22 + 23 + type AuthEntry struct { 24 + Auth string `json:"auth"` // base64(username:password) 25 + } 26 + 27 + func main() { 28 + var defaultHold string 29 + var registryURL string 30 + 31 + flag.StringVar(&defaultHold, "default-hold", "", "Default hold endpoint URL (e.g., http://172.28.0.3:8080)") 32 + flag.StringVar(&registryURL, "registry", "127.0.0.1:5000", "Registry URL to read auth from Docker config") 33 + flag.Parse() 34 + 35 + // Read Docker config 36 + home, err := os.UserHomeDir() 37 + if err != nil { 38 + log.Fatalf("Failed to get home directory: %v", err) 39 + } 40 + dockerConfigPath := filepath.Join(home, ".docker", "config.json") 41 + 42 + configData, err := os.ReadFile(dockerConfigPath) 43 + if err != nil { 44 + log.Fatalf("Failed to read Docker config: %v\n\nMake sure you've logged in with: docker login %s", err, registryURL) 45 + } 46 + 47 + var dockerConfig DockerConfig 48 + if err := json.Unmarshal(configData, &dockerConfig); err != nil { 49 + log.Fatalf("Failed to parse Docker config: %v", err) 50 + } 51 + 52 + // Get auth for registry 53 + authEntry, ok := dockerConfig.Auths[registryURL] 54 + if !ok { 55 + log.Fatalf("No auth found for registry %s in Docker config", registryURL) 56 + } 57 + 58 + // Decode base64 auth (format: "username:password") 59 + authBytes, err := base64.StdEncoding.DecodeString(authEntry.Auth) 60 + if err != nil { 61 + log.Fatalf("Failed to decode auth: %v", err) 62 + } 63 + 64 + parts := strings.SplitN(string(authBytes), ":", 2) 65 + if len(parts) != 2 { 66 + log.Fatalf("Invalid auth format") 67 + } 68 + 69 + handle := parts[0] 70 + password := parts[1] // This should be an app password 71 + 72 + fmt.Printf("Handle: %s\n", handle) 73 + 74 + // Create session validator and get access token 75 + validator := atprotoAuth.NewSessionValidator() 76 + ctx := context.Background() 77 + 78 + did, pdsEndpoint, accessToken, err := validator.CreateSessionAndGetToken(ctx, handle, password) 79 + if err != nil { 80 + log.Fatalf("Failed to authenticate: %v", err) 81 + } 82 + 83 + fmt.Printf("DID: %s\n", did) 84 + fmt.Printf("PDS: %s\n\n", pdsEndpoint) 85 + 86 + // Create client with the access token from createSession 87 + client := atproto.NewClient(pdsEndpoint, did, accessToken) 88 + 89 + // Get current profile 90 + profile, err := atproto.GetProfile(ctx, client) 91 + if err != nil { 92 + log.Fatalf("Failed to get current profile: %v", err) 93 + } 94 + 95 + if profile == nil { 96 + if defaultHold == "" { 97 + fmt.Println("No existing profile found.") 98 + fmt.Println("\nTo create profile with default hold, use: -default-hold <url>") 99 + return 100 + } 101 + fmt.Println("No existing profile found. Creating new profile...") 102 + profile = atproto.NewSailorProfileRecord(defaultHold) 103 + } else { 104 + fmt.Printf("Current defaultHold: %s\n", profile.DefaultHold) 105 + if defaultHold == "" { 106 + // Just show current profile 107 + fmt.Println("\nTo update, use: -default-hold <url>") 108 + return 109 + } 110 + profile.DefaultHold = defaultHold 111 + } 112 + 113 + // Update profile 114 + if defaultHold != "" { 115 + err = atproto.UpdateProfile(ctx, client, profile) 116 + if err != nil { 117 + log.Fatalf("Failed to update profile: %v", err) 118 + } 119 + 120 + fmt.Printf("\n✓ Updated defaultHold to: %s\n", defaultHold) 121 + } 122 + }
+20 -1
docker-compose.yml
··· 11 11 # Only auth keys (could be moved to secrets in production) 12 12 - atcr-auth:/var/lib/atcr/auth 13 13 restart: unless-stopped 14 + networks: 15 + atcr-network: 16 + ipv4_address: 172.28.0.2 14 17 # The registry should be stateless - all storage is external: 15 18 # - Manifests/Tags -> ATProto PDS 16 19 # - Blobs/Layers -> Hold service 17 20 # Future: Add read_only: true for production deployments 18 21 19 22 hold: 23 + environment: 24 + HOLD_PUBLIC_URL: http://172.28.0.3:8080 25 + HOLD_OWNER: did:plc:pddp4xt5lgnv2qsegbzzs4xg 26 + HOLD_PUBLIC: false 27 + STORAGE_DRIVER: filesystem 28 + STORAGE_ROOT_DIR: /var/lib/atcr/hold 29 + TEST_MODE: true 20 30 build: 21 31 context: . 22 32 dockerfile: Dockerfile.hold ··· 27 37 volumes: 28 38 - atcr-hold:/var/lib/atcr/hold 29 39 restart: unless-stopped 40 + networks: 41 + atcr-network: 42 + ipv4_address: 172.28.0.3 43 + 44 + networks: 45 + atcr-network: 46 + driver: bridge 47 + ipam: 48 + config: 49 + - subnet: 172.28.0.0/24 30 50 31 51 volumes: 32 - atcr-blobs: 33 52 atcr-hold: 34 53 atcr-auth:
+5 -5
docs/BYOS.md
··· 174 174 175 175 **Standard registration workflow:** 176 176 177 - 1. Set `HOLD_CREW_OWNER` to your DID: 177 + 1. Set `HOLD_OWNER` to your DID: 178 178 ```bash 179 - export HOLD_CREW_OWNER=did:plc:your-did-here 179 + export HOLD_OWNER=did:plc:your-did-here 180 180 ``` 181 181 182 182 2. Start the hold service: ··· 249 249 # Set secrets 250 250 fly secrets set AWS_ACCESS_KEY_ID=... 251 251 fly secrets set AWS_SECRET_ACCESS_KEY=... 252 - fly secrets set HOLD_CREW_OWNER=did:plc:your-did-here 252 + fly secrets set HOLD_OWNER=did:plc:your-did-here 253 253 254 254 # Check logs for OAuth URL on first run 255 255 fly logs ··· 404 404 1. **Set environment variables**: 405 405 ```bash 406 406 export HOLD_PUBLIC_URL=https://alice-storage.fly.dev 407 - export HOLD_CREW_OWNER=did:plc:alice123 407 + export HOLD_OWNER=did:plc:alice123 408 408 export STORAGE_DRIVER=s3 409 409 export AWS_ACCESS_KEY_ID=your_storj_access_key 410 410 export AWS_SECRET_ACCESS_KEY=your_storj_secret_key ··· 423 423 1. **Deploy hold service** with S3 credentials and auto-registration: 424 424 ```bash 425 425 export HOLD_PUBLIC_URL=https://company-hold.fly.dev 426 - export HOLD_CREW_OWNER=did:plc:admin 426 + export HOLD_OWNER=did:plc:admin 427 427 export HOLD_PUBLIC=false 428 428 export STORAGE_DRIVER=s3 429 429 export AWS_ACCESS_KEY_ID=...
+30 -5
pkg/atproto/client.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "crypto/ecdsa" 6 7 "encoding/json" 7 8 "fmt" 8 9 "io" ··· 15 16 did string 16 17 accessToken string 17 18 httpClient *http.Client 19 + useDPoP bool // true if using DPoP-bound tokens (OAuth) 18 20 } 19 21 20 - // NewClient creates a new ATProto client 22 + // NewClient creates a new ATProto client for Basic Auth tokens 21 23 func NewClient(pdsEndpoint, did, accessToken string) *Client { 22 24 return &Client{ 23 25 pdsEndpoint: pdsEndpoint, 24 26 did: did, 25 27 accessToken: accessToken, 26 28 httpClient: &http.Client{}, 29 + useDPoP: false, // Basic Auth uses Bearer tokens 27 30 } 28 31 } 29 32 33 + // NewClientWithDPoP creates a new ATProto client with DPoP support 34 + // This is required for OAuth tokens 35 + func NewClientWithDPoP(pdsEndpoint, did, accessToken string, dpopKey *ecdsa.PrivateKey, transport http.RoundTripper) *Client { 36 + return &Client{ 37 + pdsEndpoint: pdsEndpoint, 38 + did: did, 39 + accessToken: accessToken, 40 + httpClient: &http.Client{ 41 + Transport: transport, 42 + }, 43 + useDPoP: true, // OAuth uses DPoP tokens 44 + } 45 + } 46 + 47 + // authHeader returns the appropriate Authorization header value 48 + func (c *Client) authHeader() string { 49 + if c.useDPoP { 50 + return "DPoP " + c.accessToken 51 + } 52 + return "Bearer " + c.accessToken 53 + } 54 + 30 55 // Record represents a generic ATProto record 31 56 type Record struct { 32 57 URI string `json:"uri"` ··· 57 82 return nil, err 58 83 } 59 84 60 - req.Header.Set("Authorization", "Bearer "+c.accessToken) 85 + req.Header.Set("Authorization", c.authHeader()) 61 86 req.Header.Set("Content-Type", "application/json") 62 87 63 88 resp, err := c.httpClient.Do(req) ··· 89 114 return nil, err 90 115 } 91 116 92 - req.Header.Set("Authorization", "Bearer "+c.accessToken) 117 + req.Header.Set("Authorization", c.authHeader()) 93 118 94 119 resp, err := c.httpClient.Do(req) 95 120 if err != nil { ··· 133 158 return err 134 159 } 135 160 136 - req.Header.Set("Authorization", "Bearer "+c.accessToken) 161 + req.Header.Set("Authorization", c.authHeader()) 137 162 req.Header.Set("Content-Type", "application/json") 138 163 139 164 resp, err := c.httpClient.Do(req) ··· 160 185 return nil, err 161 186 } 162 187 163 - req.Header.Set("Authorization", "Bearer "+c.accessToken) 188 + req.Header.Set("Authorization", c.authHeader()) 164 189 165 190 resp, err := c.httpClient.Do(req) 166 191 if err != nil {
+12 -3
pkg/auth/oauth/client.go
··· 58 58 59 59 c.metadata = metadata 60 60 61 - // Configure OAuth2 client 61 + // Configure OAuth2 client with default scope 62 + // Can be overridden with SetScopes() before calling AuthorizeURL() 62 63 c.config = &oauth2.Config{ 63 64 ClientID: c.clientID, 64 65 Endpoint: oauth2.Endpoint{ 65 - AuthURL: metadata.AuthorizationEndpoint, 66 - TokenURL: metadata.TokenEndpoint, 66 + AuthURL: metadata.AuthorizationEndpoint, 67 + TokenURL: metadata.TokenEndpoint, 68 + PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint, 67 69 }, 68 70 RedirectURL: c.redirectURI, 69 71 Scopes: []string{"atproto"}, 70 72 } 71 73 72 74 return nil 75 + } 76 + 77 + // SetScopes sets custom OAuth scopes (must be called after InitializeForHandle) 78 + func (c *Client) SetScopes(scopes []string) { 79 + if c.config != nil { 80 + c.config.Scopes = scopes 81 + } 73 82 } 74 83 75 84 // AuthorizeURL generates the authorization URL with PKCE
+59 -6
pkg/auth/oauth/discovery.go
··· 7 7 "net/http" 8 8 ) 9 9 10 + // ProtectedResourceMetadata represents the OAuth protected resource metadata 11 + // as defined in ATProto OAuth spec 12 + type ProtectedResourceMetadata struct { 13 + Resource string `json:"resource"` 14 + AuthorizationServers []string `json:"authorization_servers"` 15 + } 16 + 10 17 // AuthServerMetadata represents the OAuth authorization server metadata 11 18 // as defined in RFC 8414 12 19 type AuthServerMetadata struct { ··· 25 32 AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` 26 33 } 27 34 35 + // DiscoverProtectedResource discovers the protected resource metadata 36 + // from the PDS endpoint to find the authorization servers 37 + func DiscoverProtectedResource(ctx context.Context, pdsEndpoint string) (*ProtectedResourceMetadata, error) { 38 + // Construct the well-known URL per ATProto OAuth spec 39 + discoveryURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", pdsEndpoint) 40 + 41 + req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil) 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to create protected resource discovery request: %w", err) 44 + } 45 + 46 + client := &http.Client{} 47 + resp, err := client.Do(req) 48 + if err != nil { 49 + return nil, fmt.Errorf("failed to fetch protected resource metadata: %w", err) 50 + } 51 + defer resp.Body.Close() 52 + 53 + if resp.StatusCode != http.StatusOK { 54 + return nil, fmt.Errorf("protected resource discovery failed with status %d", resp.StatusCode) 55 + } 56 + 57 + var metadata ProtectedResourceMetadata 58 + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { 59 + return nil, fmt.Errorf("failed to decode protected resource metadata: %w", err) 60 + } 61 + 62 + // Validate required fields 63 + if len(metadata.AuthorizationServers) == 0 { 64 + return nil, fmt.Errorf("protected resource metadata missing authorization_servers") 65 + } 66 + 67 + return &metadata, nil 68 + } 69 + 28 70 // DiscoverAuthServer discovers the OAuth authorization server metadata 29 - // from the PDS endpoint using the well-known discovery endpoint 71 + // using the ATProto two-step discovery process: 72 + // 1. Fetch protected resource metadata from PDS to get authorization server URL 73 + // 2. Fetch authorization server metadata from that URL 30 74 func DiscoverAuthServer(ctx context.Context, pdsEndpoint string) (*AuthServerMetadata, error) { 31 - // Construct the well-known URL per RFC 8414 32 - discoveryURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", pdsEndpoint) 75 + // Step 1: Discover the authorization server URL from the protected resource 76 + protectedResource, err := DiscoverProtectedResource(ctx, pdsEndpoint) 77 + if err != nil { 78 + return nil, fmt.Errorf("step 1 failed - discover protected resource from PDS %s: %w", pdsEndpoint, err) 79 + } 80 + 81 + // Use the first authorization server (ATProto spec allows multiple, but typically one) 82 + authServerURL := protectedResource.AuthorizationServers[0] 83 + 84 + // Step 2: Fetch authorization server metadata 85 + discoveryURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServerURL) 33 86 34 87 req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil) 35 88 if err != nil { 36 - return nil, fmt.Errorf("failed to create discovery request: %w", err) 89 + return nil, fmt.Errorf("step 2 failed - create request: %w", err) 37 90 } 38 91 39 92 client := &http.Client{} 40 93 resp, err := client.Do(req) 41 94 if err != nil { 42 - return nil, fmt.Errorf("failed to fetch authorization server metadata: %w", err) 95 + return nil, fmt.Errorf("step 2 failed - fetch auth server metadata from %s: %w", discoveryURL, err) 43 96 } 44 97 defer resp.Body.Close() 45 98 46 99 if resp.StatusCode != http.StatusOK { 47 - return nil, fmt.Errorf("authorization server discovery failed with status %d", resp.StatusCode) 100 + return nil, fmt.Errorf("step 2 failed - auth server discovery at %s returned status %d", discoveryURL, resp.StatusCode) 48 101 } 49 102 50 103 var metadata AuthServerMetadata
+15
pkg/auth/oauth/storage.go
··· 95 95 // Add a 60 second buffer to refresh before actual expiry 96 96 return time.Now().After(s.ExpiresAt.Add(-60 * time.Second)) 97 97 } 98 + 99 + // ParseDPoPKey parses a PEM-encoded ECDSA private key 100 + func ParseDPoPKey(pemData string) (*ecdsa.PrivateKey, error) { 101 + block, _ := pem.Decode([]byte(pemData)) 102 + if block == nil { 103 + return nil, fmt.Errorf("failed to decode PEM block") 104 + } 105 + 106 + key, err := x509.ParseECPrivateKey(block.Bytes) 107 + if err != nil { 108 + return nil, fmt.Errorf("failed to parse EC private key: %w", err) 109 + } 110 + 111 + return key, nil 112 + }
+26 -5
pkg/auth/oauth/transport.go
··· 2 2 3 3 import ( 4 4 "crypto/ecdsa" 5 + "crypto/sha256" 6 + "encoding/base64" 5 7 "fmt" 6 8 "net/http" 7 9 "sync" ··· 14 16 15 17 // DPoPTransport is an HTTP RoundTripper that adds DPoP headers to requests 16 18 type DPoPTransport struct { 17 - base http.RoundTripper 18 - dpopKey *ecdsa.PrivateKey 19 - nonce string 20 - mu sync.RWMutex // Protects nonce 19 + base http.RoundTripper 20 + dpopKey *ecdsa.PrivateKey 21 + accessToken string // For computing "ath" claim 22 + nonce string 23 + mu sync.RWMutex // Protects nonce 21 24 } 22 25 23 26 // NewDPoPTransport creates a new DPoP transport with the given private key ··· 80 83 81 84 // addDPoPHeader generates and adds a DPoP proof header to the request 82 85 func (t *DPoPTransport) addDPoPHeader(req *http.Request) error { 83 - // Read current nonce 86 + // Read current nonce and access token 84 87 t.mu.RLock() 85 88 nonce := t.nonce 89 + accessToken := t.accessToken 86 90 t.mu.RUnlock() 87 91 88 92 // Create DPoP proof claims ··· 100 104 claims.Nonce = nonce 101 105 } 102 106 107 + // Add "ath" (access token hash) if we have an access token 108 + // This is required when using DPoP with an access token 109 + if accessToken != "" { 110 + // Compute SHA-256 hash of the access token 111 + hash := sha256.Sum256([]byte(accessToken)) 112 + // Base64url encode the hash (without padding) 113 + ath := base64.RawURLEncoding.EncodeToString(hash[:]) 114 + claims.AccessTokenHash = ath 115 + } 116 + 103 117 // Generate DPoP proof 104 118 // go-dpop automatically adds the JWK to the header 105 119 proofString, err := dpop.Create(jwt.SigningMethodES256, claims, t.dpopKey) ··· 126 140 defer t.mu.RUnlock() 127 141 return t.nonce 128 142 } 143 + 144 + // SetAccessToken sets the access token for computing "ath" claim 145 + func (t *DPoPTransport) SetAccessToken(token string) { 146 + t.mu.Lock() 147 + defer t.mu.Unlock() 148 + t.accessToken = token 149 + }
+12 -3
pkg/storage/proxy_blob_store.go
··· 30 30 31 31 // NewProxyBlobStore creates a new proxy blob store 32 32 func NewProxyBlobStore(storageEndpoint, did string) *ProxyBlobStore { 33 + fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, did=%s\n", storageEndpoint, did) 33 34 return &ProxyBlobStore{ 34 35 storageEndpoint: storageEndpoint, 35 - httpClient: &http.Client{}, 36 - did: did, 36 + httpClient: &http.Client{ 37 + Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads 38 + }, 39 + did: did, 37 40 } 38 41 } 39 42 ··· 236 239 237 240 // getUploadURL requests a presigned upload URL from the storage service 238 241 func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) { 242 + fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: storageEndpoint=%s, digest=%s\n", p.storageEndpoint, dgst) 243 + 239 244 reqBody := map[string]any{ 240 245 "did": p.did, 241 246 "digest": dgst.String(), ··· 248 253 } 249 254 250 255 url := fmt.Sprintf("%s/put-presigned-url", p.storageEndpoint) 256 + fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Calling %s\n", url) 251 257 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 252 258 if err != nil { 253 259 return "", err ··· 271 277 return "", err 272 278 } 273 279 280 + fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Got presigned URL=%s\n", result.URL) 274 281 return result.URL, nil 275 282 } 276 283 ··· 311 318 if w.closed { 312 319 return 0, fmt.Errorf("writer closed") 313 320 } 314 - return w.buffer.ReadFrom(r) 321 + n, err := w.buffer.ReadFrom(r) 322 + w.size += n 323 + return n, err 315 324 } 316 325 317 326 // Size returns the current size