diff --git a/Dockerfile b/Dockerfile index 9c31b4c..1367a43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,18 +2,13 @@ FROM golang:1.23-alpine AS builder RUN apk add --no-cache gcc musl-dev git -ENV GOPRIVATE=github.com/goatnetwork/tss -ARG GITHUB_TOKEN -RUN echo "machine github.com login ${GITHUB_TOKEN} password x-oauth-basic" > ~/.netrc && \ - chmod 600 ~/.netrc - WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download - +# Copy everything including vendor directory COPY . . -RUN CGO_ENABLED=1 go build -o /goat-relayer ./cmd + +# Build with vendor mode (no network required) +RUN CGO_ENABLED=1 go build -mod=vendor -o /goat-relayer ./cmd FROM alpine:3.18 @@ -25,4 +20,4 @@ COPY --from=builder /goat-relayer /app/goat-relayer EXPOSE 8080 50051 4001 -CMD ["/app/goat-relayer"] \ No newline at end of file +CMD ["/app/goat-relayer"] diff --git a/cmd/app.go b/cmd/app.go index efe2976..eff936b 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/btcsuite/btcd/rpcclient" + "github.com/goatnetwork/goat-relayer/internal/awsutil" "github.com/goatnetwork/goat-relayer/internal/bls" "github.com/goatnetwork/goat-relayer/internal/btc" "github.com/goatnetwork/goat-relayer/internal/config" @@ -41,6 +42,7 @@ type Application struct { func NewApplication() *Application { config.InitConfig() + // Allow BTC_RPC to be either host:port or full URL so TLS can be inferred. rpcHost := config.AppConfig.BTCRPC disableTLS := true @@ -73,6 +75,10 @@ func NewApplication() *Application { if rpcPass == "" { rpcPass = "x" } + // For AWS SigV4, force HTTPS + if config.AppConfig.BTCAWSSigV4 { + disableTLS = false + } // create bitcoin client using btc module connection connConfig := &rpcclient.ConnConfig{ Host: rpcHost, @@ -87,6 +93,23 @@ func NewApplication() *Application { log.Fatalf("Failed to start bitcoin client: %v", err) } + if config.AppConfig.BTCAWSSigV4 { + if err := awsutil.AttachSigV4Signer( + bclient, + config.AppConfig.BTCAWSRegion, + config.AppConfig.BTCAWSService, + config.AppConfig.BTCRPC_USER, + config.AppConfig.BTCRPC_PASS, + os.Getenv("AWS_SESSION_TOKEN"), + ); err != nil { + log.Fatalf("Failed to enable AWS SigV4 signing: %v", err) + } + + if err := awsutil.PrimeBitcoindBackendVersion(bclient); err != nil { + log.Fatalf("Failed to detect bitcoind version: %v", err) + } + } + dbm := db.NewDatabaseManager() state := state.InitializeState(dbm) libP2PService := p2p.NewLibP2PService(state) diff --git a/go.mod b/go.mod index dfb85fd..0e6e8dc 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.8 toolchain go1.24.3 require ( + github.com/aws/aws-sdk-go v1.40.45 github.com/btcsuite/btcd v0.25.0 github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 @@ -107,6 +108,7 @@ require ( github.com/iancoleman/strcase v0.3.0 // indirect github.com/improbable-eng/grpc-web v0.15.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmhodges/levigo v1.0.0 // indirect github.com/kelindar/simd v1.1.2 // indirect github.com/libp2p/go-yamux/v5 v5.0.1 // indirect diff --git a/go.sum b/go.sum index 5208651..08d3812 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.40.45 h1:QN1nsY27ssD/JmW4s83qmSb+uL6DG4GmCDzjmJB4xUI= +github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -550,6 +552,10 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U= github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -1174,6 +1180,7 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= diff --git a/internal/awsutil/sigv4.go b/internal/awsutil/sigv4.go new file mode 100644 index 0000000..39bc8e5 --- /dev/null +++ b/internal/awsutil/sigv4.go @@ -0,0 +1,329 @@ +package awsutil + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "reflect" + "strconv" + "strings" + "sync" + "time" + "unsafe" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go/aws/ec2metadata" + "github.com/aws/aws-sdk-go/aws/session" + awsv4 "github.com/aws/aws-sdk-go/aws/signer/v4" + "github.com/btcsuite/btcd/rpcclient" +) + +// AttachSigV4Signer wraps the btc RPC client's HTTP transport with a SigV4 signer. +func AttachSigV4Signer(client *rpcclient.Client, region, service, accessKey, secretKey, sessionToken string) error { + if client == nil { + return fmt.Errorf("rpc client is nil") + } + if region == "" { + return fmt.Errorf("AWS region is empty") + } + if service == "" { + return fmt.Errorf("AWS service is empty") + } + + creds, err := loadStaticCredentials(accessKey, secretKey, sessionToken) + if err != nil { + return err + } + + httpClient, err := extractHTTPClient(client) + if err != nil { + return err + } + baseTransport := httpClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + + signer := awsv4.NewSigner(creds) + httpClient.Transport = &sigV4Transport{ + base: baseTransport, + signer: signer, + region: region, + service: service, + } + + return nil +} + +func loadStaticCredentials(accessKey, secretKey, sessionToken string) (*credentials.Credentials, error) { + // 1. Try static credentials if explicitly provided + if accessKey != "" && secretKey != "" { + return credentials.NewStaticCredentials(accessKey, secretKey, sessionToken), nil + } + + // 2. Try environment credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) + if creds := credentials.NewEnvCredentials(); creds != nil { + if value, err := creds.Get(); err == nil && value.AccessKeyID != "" && value.SecretAccessKey != "" { + return creds, nil + } + } + + // 3. Try shared credentials file (~/.aws/credentials) + profile := os.Getenv("AWS_PROFILE") + if profile == "" { + profile = "default" + } + if creds := credentials.NewSharedCredentials("", profile); creds != nil { + if value, err := creds.Get(); err == nil && value.AccessKeyID != "" && value.SecretAccessKey != "" { + return creds, nil + } + } + + // 4. Try EC2 instance role credentials (IAM role attached to EC2 instance) + // The AWS SDK automatically handles IMDSv2 token acquisition + sess, err := session.NewSession(&aws.Config{ + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + }) + if err == nil { + metadataClient := ec2metadata.New(sess) + // Check if we're running on EC2 + if metadataClient.Available() { + creds := credentials.NewCredentials(&ec2rolecreds.EC2RoleProvider{ + Client: metadataClient, + }) + if value, err := creds.Get(); err == nil && value.AccessKeyID != "" && value.SecretAccessKey != "" { + return creds, nil + } + } + } + + return nil, fmt.Errorf("unable to find AWS credentials via environment variables, shared config, or EC2 instance role") +} + +// PrimeBitcoindBackendVersion discovers the remote bitcoind version via +// getnetworkinfo and seeds the client's backendVersion cache so subsequent RPCs +// do not attempt unsupported detection calls (for example, getinfo). +func PrimeBitcoindBackendVersion(client *rpcclient.Client) error { + if client == nil { + return fmt.Errorf("rpc client is nil") + } + + resp, err := client.RawRequest("getnetworkinfo", nil) + if err != nil { + if strings.Contains(err.Error(), "status code: 403") { + return setBackendVersion(client, rpcclient.BitcoindPost25) + } + return fmt.Errorf("getnetworkinfo request failed: %w", err) + } + + // Handle empty response (e.g., AWS ManagedBlockchain doesn't support getnetworkinfo) + // Default to BitcoindPost25 in this case + if len(resp) == 0 { + return setBackendVersion(client, rpcclient.BitcoindPost25) + } + + var networkInfo struct { + SubVersion string `json:"subversion"` + Version float64 `json:"version"` + } + if err := json.Unmarshal(resp, &networkInfo); err != nil { + // If we can't decode the response, assume BitcoindPost25 + return setBackendVersion(client, rpcclient.BitcoindPost25) + } + + subVersion := networkInfo.SubVersion + if subVersion == "" { + if networkInfo.Version == 0 { + // Empty version info, assume BitcoindPost25 + return setBackendVersion(client, rpcclient.BitcoindPost25) + } + subVersion = deriveSatoshiSubVersion(networkInfo.Version) + } + + if subVersion == "" { + // No subversion, assume BitcoindPost25 + return setBackendVersion(client, rpcclient.BitcoindPost25) + } + + version := classifyBitcoindVersion(subVersion) + return setBackendVersion(client, version) +} + +func deriveSatoshiSubVersion(version float64) string { + if version <= 0 { + return "" + } + + intVersion := int(version) + major := intVersion / 1000000 + minor := (intVersion / 10000) % 100 + patch := (intVersion / 100) % 100 + + return fmt.Sprintf("/Satoshi:%d.%d.%d/", major, minor, patch) +} + +func classifyBitcoindVersion(subVersion string) rpcclient.BitcoindVersion { + trimmed := strings.TrimPrefix(strings.TrimSuffix(subVersion, "/"), "/Satoshi:") + maj, min, patch := parseSemver(trimmed) + + switch { + case versionLessThan(maj, min, patch, 0, 19, 0): + return rpcclient.BitcoindPre19 + case versionLessThan(maj, min, patch, 22, 0, 0): + return rpcclient.BitcoindPre22 + case versionLessThan(maj, min, patch, 24, 0, 0): + return rpcclient.BitcoindPre24 + case versionLessThan(maj, min, patch, 25, 0, 0): + return rpcclient.BitcoindPre25 + default: + return rpcclient.BitcoindPost25 + } +} + +func parseSemver(v string) (int, int, int) { + parts := strings.Split(v, ".") + values := [3]int{} + for i := 0; i < len(values) && i < len(parts); i++ { + if intVal, err := strconv.Atoi(parts[i]); err == nil { + values[i] = intVal + } + } + return values[0], values[1], values[2] +} + +func versionLessThan(majorA, minorA, patchA, majorB, minorB, patchB int) bool { + if majorA != majorB { + return majorA < majorB + } + if minorA != minorB { + return minorA < minorB + } + return patchA < patchB +} + +func setBackendVersion(client *rpcclient.Client, version rpcclient.BackendVersion) error { + if client == nil { + return fmt.Errorf("rpc client is nil") + } + + val := reflect.ValueOf(client) + if val.Kind() != reflect.Ptr || val.IsNil() { + return fmt.Errorf("rpc client is invalid") + } + clientVal := val.Elem() + + muField := clientVal.FieldByName("backendVersionMu") + if !muField.IsValid() { + return fmt.Errorf("rpc client missing backendVersionMu field") + } + muPtr := (*sync.Mutex)(unsafe.Pointer(muField.UnsafeAddr())) + muPtr.Lock() + defer muPtr.Unlock() + + versionField := clientVal.FieldByName("backendVersion") + if !versionField.IsValid() { + return fmt.Errorf("rpc client missing backendVersion field") + } + reflect.NewAt(versionField.Type(), unsafe.Pointer(versionField.UnsafeAddr())).Elem().Set(reflect.ValueOf(version)) + + return nil +} + +func extractHTTPClient(client *rpcclient.Client) (*http.Client, error) { + rv := reflect.ValueOf(client) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return nil, fmt.Errorf("rpc client is invalid") + } + elem := rv.Elem() + field := elem.FieldByName("httpClient") + if !field.IsValid() { + return nil, fmt.Errorf("rpc client does not expose httpClient field") + } + if field.IsNil() { + return nil, fmt.Errorf("rpc client httpClient is nil") + } + + ptr := unsafe.Pointer(field.UnsafeAddr()) + httpClient := *(**http.Client)(ptr) + if httpClient == nil { + return nil, fmt.Errorf("rpc client httpClient pointer is nil") + } + + return httpClient, nil +} + +type sigV4Transport struct { + base http.RoundTripper + signer *awsv4.Signer + region string + service string +} + +func (t *sigV4Transport) RoundTrip(req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("request is nil") + } + + bodyReader, err := cloneRequestBody(req) + if err != nil { + return nil, err + } + + req.Header.Del("Authorization") + req.Header.Del("X-Amz-Date") + req.Header.Del("X-Amz-Content-Sha256") + req.Header.Del("X-Amz-Security-Token") + + if _, err := t.signer.Sign(req, bodyReader, t.service, t.region, time.Now()); err != nil { + return nil, fmt.Errorf("failed to sign request: %w", err) + } + + if _, err := bodyReader.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("failed to reset request body reader: %w", err) + } + + return t.base.RoundTrip(req) +} + +func cloneRequestBody(req *http.Request) (io.ReadSeeker, error) { + if req.Body == nil { + return bytes.NewReader(nil), nil + } + + if req.GetBody != nil { + rc, err := req.GetBody() + if err != nil { + return nil, fmt.Errorf("failed to clone request body: %w", err) + } + defer rc.Close() + buf, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("failed to read request body: %w", err) + } + return bytes.NewReader(buf), nil + } + + buf, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("failed to read request body: %w", err) + } + if err := req.Body.Close(); err != nil { + return nil, fmt.Errorf("failed to close original request body: %w", err) + } + + reader := bytes.NewReader(buf) + req.Body = io.NopCloser(bytes.NewReader(buf)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(buf)), nil + } + req.ContentLength = int64(len(buf)) + + return reader, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 9c5ea26..046fba3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,9 @@ func InitConfig() { viper.SetDefault("BTC_MAX_NETWORK_FEE", 500) viper.SetDefault("BTC_MAX_RANGE", 50) viper.SetDefault("BTC_REINDEX_BLOCKS", "") + viper.SetDefault("BTC_AWS_SIGV4", false) + viper.SetDefault("BTC_AWS_REGION", "us-east-1") + viper.SetDefault("BTC_AWS_SERVICE", "managedblockchain") viper.SetDefault("CONTRACT_TASK_MANAGER", "0x6827D591faDa19A1274Df0Ab2608901AaaEA14C9") viper.SetDefault("L2_RPC", "http://localhost:8545") viper.SetDefault("L2_JWT_SECRET", "") @@ -86,6 +89,9 @@ func InitConfig() { BTCRPC_USER: viper.GetString("BTC_RPC_USER"), BTCRPC_PASS: viper.GetString("BTC_RPC_PASS"), BTCRPCApiKey: viper.GetString("BTC_RPC_API_KEY"), + BTCAWSSigV4: viper.GetBool("BTC_AWS_SIGV4"), + BTCAWSRegion: viper.GetString("BTC_AWS_REGION"), + BTCAWSService: viper.GetString("BTC_AWS_SERVICE"), BTCStartHeight: viper.GetInt("BTC_START_HEIGHT"), BTCReindexBlocks: viper.GetString("BTC_REINDEX_BLOCKS"), BTCConfirmations: viper.GetInt("BTC_CONFIRMATIONS"), @@ -145,6 +151,9 @@ type Config struct { BTCRPC_USER string BTCRPC_PASS string BTCRPCApiKey string + BTCAWSSigV4 bool + BTCAWSRegion string + BTCAWSService string BTCStartHeight int BTCConfirmations int BTCNetworkType string