@@ -4,11 +4,13 @@ import (
44 "bytes"
55 "context"
66 "crypto"
7+ "crypto/hmac"
78 "crypto/rand"
89 "crypto/rsa"
910 "crypto/sha256"
1011 "crypto/x509"
1112 "encoding/base64"
13+ "encoding/hex"
1214 "encoding/json"
1315 "encoding/pem"
1416 "errors"
@@ -19,6 +21,7 @@ import (
1921 "os"
2022 "path"
2123 "path/filepath"
24+ "sort"
2225 "strings"
2326 "syscall"
2427 "time"
@@ -29,12 +32,16 @@ const (
2932 defaultStatePath = "/tmp/beacon/shuttle-state.json"
3033 defaultTokenURI = "https://oauth2.googleapis.com/token"
3134 defaultGCSEndpoint = "https://storage.googleapis.com"
35+ defaultS3Region = "us-east-1"
3236 contentTypeJSONL = "text/plain; charset=utf-8"
37+ uploadGCS = "gcs"
38+ uploadS3 = "s3"
3339)
3440
3541var httpClient = http .DefaultClient
3642
3743type Config struct {
44+ Upload string
3845 LogPath string
3946 StatePath string
4047 Bucket string
@@ -45,6 +52,11 @@ type Config struct {
4552 UserID string
4653 Repository string
4754 GCSEndpoint string
55+ S3Region string
56+ S3AccessKeyID string
57+ S3SecretKey string
58+ S3SessionToken string
59+ S3Endpoint string
4860}
4961
5062type serviceAccount struct {
@@ -67,17 +79,30 @@ type tokenResponse struct {
6779
6880func ConfigFromEnv () Config {
6981 statePath := firstEnvDefault (defaultStatePath , "BEACON_CLOUD_SHUTTLE_STATE" )
82+ upload := cloudUploadFromEnv ()
83+ bucket := strings .TrimSpace (os .Getenv ("BEACON_CLOUD_GCS_BUCKET" ))
84+ prefix := strings .Trim (strings .TrimSpace (os .Getenv ("BEACON_CLOUD_GCS_PREFIX" )), "/" )
85+ if upload == uploadS3 {
86+ bucket = strings .TrimSpace (os .Getenv ("BEACON_CLOUD_S3_BUCKET" ))
87+ prefix = strings .Trim (strings .TrimSpace (os .Getenv ("BEACON_CLOUD_S3_PREFIX" )), "/" )
88+ }
7089 return Config {
90+ Upload : upload ,
7191 LogPath : firstEnvDefault (defaultLogPath , "BEACON_CLOUD_LOG_PATH" , "BEACON_ENDPOINT_LOG" , "BEACON_LOG_PATH" , "BEACON_RUNTIME_LOG" ),
7292 StatePath : statePath ,
73- Bucket : strings . TrimSpace ( os . Getenv ( "BEACON_CLOUD_GCS_BUCKET" )) ,
74- Prefix : strings . Trim ( strings . TrimSpace ( os . Getenv ( "BEACON_CLOUD_GCS_PREFIX" )), "/" ) ,
93+ Bucket : bucket ,
94+ Prefix : prefix ,
7595 CredentialsB64 : strings .TrimSpace (os .Getenv ("BEACON_CLOUD_GCS_CREDENTIALS_B64" )),
7696 Provider : firstEnvDefault ("claude_code_web" , "BEACON_RUN_PROVIDER" ),
7797 RunID : resolveRunID (),
7898 UserID : firstEnvDefault ("unknown" , "BEACON_CLOUD_USER_ID_HASH" , "BEACON_CLOUD_USER_ID" ),
7999 Repository : firstEnv ("BEACON_RUN_REPOSITORY" ),
80100 GCSEndpoint : firstEnvDefault (defaultGCSEndpoint , "BEACON_CLOUD_GCS_ENDPOINT" ),
101+ S3Region : firstEnvDefault (defaultS3Region , "BEACON_CLOUD_S3_REGION" , "AWS_REGION" , "AWS_DEFAULT_REGION" ),
102+ S3AccessKeyID : firstEnv ("AWS_ACCESS_KEY_ID" ),
103+ S3SecretKey : firstEnv ("AWS_SECRET_ACCESS_KEY" ),
104+ S3SessionToken : firstEnv ("AWS_SESSION_TOKEN" ),
105+ S3Endpoint : firstEnv ("BEACON_CLOUD_S3_ENDPOINT" ),
81106 }
82107}
83108
@@ -105,21 +130,13 @@ func ResetFromEnv() error {
105130}
106131
107132func Upload (ctx context.Context , cfg Config , force bool ) error {
108- if strings .TrimSpace (cfg .Bucket ) == "" || strings .TrimSpace (cfg .CredentialsB64 ) == "" {
133+ cfg = normalizeConfig (cfg )
134+ if ! uploadConfigured (cfg ) {
109135 return nil
110136 }
111137 if strings .TrimSpace (cfg .RunID ) == "" {
112138 return nil
113139 }
114- if cfg .LogPath == "" {
115- cfg .LogPath = defaultLogPath
116- }
117- if cfg .StatePath == "" {
118- cfg .StatePath = defaultStatePath
119- }
120- if cfg .GCSEndpoint == "" {
121- cfg .GCSEndpoint = defaultGCSEndpoint
122- }
123140 info , err := os .Stat (cfg .LogPath )
124141 if err != nil {
125142 if os .IsNotExist (err ) {
@@ -138,12 +155,8 @@ func Upload(ctx context.Context, cfg Config, force bool) error {
138155 return err
139156 }
140157 defer cleanup ()
141- token , err := accessToken (ctx , cfg .CredentialsB64 )
142- if err != nil {
143- return err
144- }
145158 objectName := ObjectName (cfg )
146- if err := uploadObject (ctx , cfg . GCSEndpoint , cfg . Bucket , objectName , snapshot , token ); err != nil {
159+ if err := uploadSnapshot (ctx , cfg , objectName , snapshot ); err != nil {
147160 return err
148161 }
149162 return writeState (cfg .StatePath , state {
@@ -311,7 +324,20 @@ func parseRSAPrivateKey(raw string) (*rsa.PrivateKey, error) {
311324 return nil , errors .New ("parse GCS private key" )
312325}
313326
314- func uploadObject (ctx context.Context , endpoint , bucket , objectName , filePath , token string ) error {
327+ func uploadSnapshot (ctx context.Context , cfg Config , objectName , filePath string ) error {
328+ switch cfg .Upload {
329+ case uploadS3 :
330+ return uploadS3Object (ctx , cfg , objectName , filePath )
331+ default :
332+ token , err := accessToken (ctx , cfg .CredentialsB64 )
333+ if err != nil {
334+ return err
335+ }
336+ return uploadGCSObject (ctx , cfg .GCSEndpoint , cfg .Bucket , objectName , filePath , token )
337+ }
338+ }
339+
340+ func uploadGCSObject (ctx context.Context , endpoint , bucket , objectName , filePath , token string ) error {
315341 file , err := os .Open (filePath )
316342 if err != nil {
317343 return err
@@ -339,6 +365,94 @@ func uploadObject(ctx context.Context, endpoint, bucket, objectName, filePath, t
339365 return nil
340366}
341367
368+ func uploadS3Object (ctx context.Context , cfg Config , objectName , filePath string ) error {
369+ file , err := os .Open (filePath )
370+ if err != nil {
371+ return err
372+ }
373+ defer file .Close ()
374+ payloadHash , err := hashOpenFile (file )
375+ if err != nil {
376+ return err
377+ }
378+ if _ , err := file .Seek (0 , io .SeekStart ); err != nil {
379+ return err
380+ }
381+ endpoint := strings .TrimRight (cfg .S3Endpoint , "/" )
382+ if endpoint == "" {
383+ endpoint = "https://s3." + cfg .S3Region + ".amazonaws.com"
384+ }
385+ uploadURL := endpoint + "/" + escapePath (cfg .Bucket ) + "/" + escapeObjectName (objectName )
386+ req , err := http .NewRequestWithContext (ctx , http .MethodPut , uploadURL , file )
387+ if err != nil {
388+ return err
389+ }
390+ if info , err := file .Stat (); err == nil {
391+ req .ContentLength = info .Size ()
392+ }
393+ req .Header .Set ("Content-Type" , contentTypeJSONL )
394+ req .Header .Set ("X-Amz-Content-Sha256" , payloadHash )
395+ req .Header .Set ("X-Amz-Date" , time .Now ().UTC ().Format ("20060102T150405Z" ))
396+ if cfg .S3SessionToken != "" {
397+ req .Header .Set ("X-Amz-Security-Token" , cfg .S3SessionToken )
398+ }
399+ signS3Request (req , cfg , payloadHash )
400+ resp , err := httpClient .Do (req )
401+ if err != nil {
402+ return err
403+ }
404+ defer resp .Body .Close ()
405+ body , _ := io .ReadAll (io .LimitReader (resp .Body , 64 * 1024 ))
406+ if resp .StatusCode < 200 || resp .StatusCode >= 300 {
407+ return fmt .Errorf ("S3 upload failed: %s: %s" , resp .Status , strings .TrimSpace (string (body )))
408+ }
409+ return nil
410+ }
411+
412+ func signS3Request (req * http.Request , cfg Config , payloadHash string ) {
413+ amzDate := req .Header .Get ("X-Amz-Date" )
414+ dateStamp := amzDate [:8 ]
415+ credentialScope := dateStamp + "/" + cfg .S3Region + "/s3/aws4_request"
416+ headers := map [string ]string {
417+ "content-type" : req .Header .Get ("Content-Type" ),
418+ "host" : req .URL .Host ,
419+ "x-amz-content-sha256" : payloadHash ,
420+ "x-amz-date" : amzDate ,
421+ }
422+ if token := req .Header .Get ("X-Amz-Security-Token" ); token != "" {
423+ headers ["x-amz-security-token" ] = token
424+ }
425+ keys := make ([]string , 0 , len (headers ))
426+ for key := range headers {
427+ keys = append (keys , key )
428+ }
429+ sort .Strings (keys )
430+ var canonicalHeaders strings.Builder
431+ for _ , key := range keys {
432+ canonicalHeaders .WriteString (key )
433+ canonicalHeaders .WriteString (":" )
434+ canonicalHeaders .WriteString (strings .TrimSpace (headers [key ]))
435+ canonicalHeaders .WriteString ("\n " )
436+ }
437+ signedHeaders := strings .Join (keys , ";" )
438+ canonicalRequest := strings .Join ([]string {
439+ req .Method ,
440+ req .URL .EscapedPath (),
441+ req .URL .RawQuery ,
442+ canonicalHeaders .String (),
443+ signedHeaders ,
444+ payloadHash ,
445+ }, "\n " )
446+ stringToSign := strings .Join ([]string {
447+ "AWS4-HMAC-SHA256" ,
448+ amzDate ,
449+ credentialScope ,
450+ sha256Hex ([]byte (canonicalRequest )),
451+ }, "\n " )
452+ signature := hex .EncodeToString (hmacSHA256 (s3SigningKey (cfg .S3SecretKey , dateStamp , cfg .S3Region ), stringToSign ))
453+ req .Header .Set ("Authorization" , "AWS4-HMAC-SHA256 Credential=" + cfg .S3AccessKeyID + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature )
454+ }
455+
342456func readState (path string ) (state , error ) {
343457 var st state
344458 data , err := os .ReadFile (path )
@@ -361,6 +475,7 @@ func writeState(path string, st state) error {
361475}
362476
363477func preserveExistingLog (cfg Config ) error {
478+ cfg = normalizeConfig (cfg )
364479 info , err := os .Stat (cfg .LogPath )
365480 if err != nil {
366481 if os .IsNotExist (err ) {
@@ -371,16 +486,14 @@ func preserveExistingLog(cfg Config) error {
371486 if info .Size () == 0 {
372487 return os .Remove (cfg .LogPath )
373488 }
374- if st , err := readState (cfg .StatePath ); err == nil && st .LastObject != "" && cfg . Bucket != "" && cfg . CredentialsB64 != "" {
489+ if st , err := readState (cfg .StatePath ); err == nil && st .LastObject != "" && uploadConfigured ( cfg ) {
375490 snapshot , cleanup , err := snapshotLog (cfg .LogPath )
376491 if err == nil {
377492 defer cleanup ()
378493 ctx , cancel := context .WithTimeout (context .Background (), 10 * time .Second )
379494 defer cancel ()
380- if token , tokenErr := accessToken (ctx , cfg .CredentialsB64 ); tokenErr == nil {
381- if uploadErr := uploadObject (ctx , cfg .GCSEndpoint , cfg .Bucket , st .LastObject , snapshot , token ); uploadErr == nil {
382- return os .Remove (cfg .LogPath )
383- }
495+ if uploadErr := uploadSnapshot (ctx , cfg , st .LastObject , snapshot ); uploadErr == nil {
496+ return os .Remove (cfg .LogPath )
384497 }
385498 }
386499 }
@@ -395,6 +508,80 @@ func resolveRunID() string {
395508 return firstEnv ("BEACON_RUN_ID" , "CLAUDE_CODE_REMOTE_SESSION_ID" )
396509}
397510
511+ func cloudUploadFromEnv () string {
512+ value := strings .ToLower (strings .TrimSpace (os .Getenv ("BEACON_CLOUD_UPLOAD" )))
513+ switch value {
514+ case uploadS3 :
515+ return uploadS3
516+ case uploadGCS :
517+ return uploadGCS
518+ }
519+ return uploadGCS
520+ }
521+
522+ func normalizeConfig (cfg Config ) Config {
523+ cfg .Upload = strings .ToLower (strings .TrimSpace (cfg .Upload ))
524+ if cfg .Upload == "" {
525+ if cfg .S3AccessKeyID != "" || cfg .S3SecretKey != "" || cfg .S3Region != "" || strings .Contains (cfg .S3Endpoint , "s3" ) {
526+ cfg .Upload = uploadS3
527+ } else {
528+ cfg .Upload = uploadGCS
529+ }
530+ }
531+ if cfg .LogPath == "" {
532+ cfg .LogPath = defaultLogPath
533+ }
534+ if cfg .StatePath == "" {
535+ cfg .StatePath = defaultStatePath
536+ }
537+ if cfg .GCSEndpoint == "" {
538+ cfg .GCSEndpoint = defaultGCSEndpoint
539+ }
540+ if cfg .S3Region == "" {
541+ cfg .S3Region = defaultS3Region
542+ }
543+ cfg .Prefix = strings .Trim (cfg .Prefix , "/" )
544+ return cfg
545+ }
546+
547+ func uploadConfigured (cfg Config ) bool {
548+ if strings .TrimSpace (cfg .Bucket ) == "" {
549+ return false
550+ }
551+ switch cfg .Upload {
552+ case uploadS3 :
553+ return strings .TrimSpace (cfg .S3AccessKeyID ) != "" && strings .TrimSpace (cfg .S3SecretKey ) != ""
554+ default :
555+ return strings .TrimSpace (cfg .CredentialsB64 ) != ""
556+ }
557+ }
558+
559+ func hashOpenFile (file * os.File ) (string , error ) {
560+ hash := sha256 .New ()
561+ if _ , err := io .Copy (hash , file ); err != nil {
562+ return "" , err
563+ }
564+ return hex .EncodeToString (hash .Sum (nil )), nil
565+ }
566+
567+ func sha256Hex (data []byte ) string {
568+ sum := sha256 .Sum256 (data )
569+ return hex .EncodeToString (sum [:])
570+ }
571+
572+ func s3SigningKey (secret , dateStamp , region string ) []byte {
573+ dateKey := hmacSHA256 ([]byte ("AWS4" + secret ), dateStamp )
574+ regionKey := hmacSHA256 (dateKey , region )
575+ serviceKey := hmacSHA256 (regionKey , "s3" )
576+ return hmacSHA256 (serviceKey , "aws4_request" )
577+ }
578+
579+ func hmacSHA256 (key []byte , value string ) []byte {
580+ mac := hmac .New (sha256 .New , key )
581+ mac .Write ([]byte (value ))
582+ return mac .Sum (nil )
583+ }
584+
398585func cleanKeyParts (value string ) []string {
399586 var parts []string
400587 for _ , part := range strings .Split (strings .Trim (value , "/" ), "/" ) {
0 commit comments