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.

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