├── terraform ├── modules │ └── lambda │ │ ├── providers.tf │ │ ├── output.tf │ │ ├── main.tf │ │ └── variables.tf └── main │ ├── backend.tf │ ├── outputs.tf │ ├── variables.tf │ ├── providers.tf │ └── main.tf ├── cmd ├── lambda │ ├── go.mod │ ├── go.sum │ └── main.go ├── example_multi_region │ └── main.go └── example_client │ └── main.go ├── go.mod ├── .gitignore ├── go.sum ├── Makefile ├── requests.go ├── transport_roundrobin.go ├── transport_http_test.go ├── client.go ├── server.go ├── transport_http.go ├── README.md └── LICENSE /terraform/modules/lambda/providers.tf: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | version = "~> 5.60.0" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cmd/lambda/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/myzie/burrow/lambda 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/aws/aws-lambda-go v1.47.0 7 | github.com/myzie/burrow v0.0.1 8 | ) 9 | 10 | replace github.com/myzie/burrow => ../.. 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/myzie/burrow 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/pmezard/go-difflib v1.0.0 // indirect 8 | github.com/stretchr/testify v1.9.0 // indirect 9 | gopkg.in/yaml.v3 v3.0.1 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /terraform/modules/lambda/output.tf: -------------------------------------------------------------------------------- 1 | 2 | output "arn" { 3 | value = aws_lambda_function.lambda.arn 4 | } 5 | 6 | output "name" { 7 | value = aws_lambda_function.lambda.function_name 8 | } 9 | 10 | output "url" { 11 | value = aws_lambda_function_url.lambda.function_url 12 | } 13 | -------------------------------------------------------------------------------- /terraform/main/backend.tf: -------------------------------------------------------------------------------- 1 | 2 | # This file is used to configure the backend for the terraform state file. 3 | # The S3 bucket (and optionally the key and region) should be overriden with 4 | # your desired values when running "terraform init". For example: 5 | # 6 | # terraform init -backend-config=my-bucket-1234 7 | 8 | terraform { 9 | backend "s3" { 10 | bucket = "" 11 | key = "states/burrow/terraform.tfstate" 12 | region = "us-east-1" 13 | } 14 | required_providers { 15 | aws = { 16 | source = "hashicorp/aws" 17 | version = "~> 5.60.0" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | /dist 25 | /function_urls.json 26 | 27 | .terraform 28 | .terraform.lock.hcl 29 | 30 | *.json 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 6 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 8 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 9 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 10 | -------------------------------------------------------------------------------- /cmd/lambda/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= 2 | github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= 8 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /terraform/main/outputs.tf: -------------------------------------------------------------------------------- 1 | 2 | output "function_urls" { 3 | value = { 4 | "us-east-1" = module.region-us-east-1.url 5 | "us-east-2" = module.region-us-east-2.url 6 | "us-west-1" = module.region-us-west-1.url 7 | "us-west-2" = module.region-us-west-2.url 8 | "eu-west-1" = module.region-eu-west-1.url 9 | "eu-west-2" = module.region-eu-west-2.url 10 | "eu-west-3" = module.region-eu-west-3.url 11 | "eu-central-1" = module.region-eu-central-1.url 12 | "sa-east-1" = module.region-sa-east-1.url 13 | "eu-north-1" = module.region-eu-north-1.url 14 | "ca-central-1" = module.region-ca-central-1.url 15 | "ap-south-1" = module.region-ap-south-1.url 16 | "ap-northeast-1" = module.region-ap-northeast-1.url 17 | "ap-northeast-2" = module.region-ap-northeast-2.url 18 | "ap-northeast-3" = module.region-ap-northeast-3.url 19 | "ap-southeast-1" = module.region-ap-southeast-1.url 20 | "ap-southeast-2" = module.region-ap-southeast-2.url 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: deploy 2 | 3 | APP_NAME?=burrow 4 | 5 | GIT_REVISION=$(shell git rev-parse --short HEAD) 6 | 7 | AWS_ACCOUNT_ID=$(shell aws sts get-caller-identity --query Account --output text) 8 | 9 | AWS_REGION?=us-east-1 10 | 11 | LAMBDA_BUILD=CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build 12 | 13 | LAMBDA_BINARY=dist/burrow-$(GIT_REVISION).zip 14 | 15 | BUCKET_NAME?=terraform-$(AWS_ACCOUNT_ID) 16 | 17 | AUTO_APPROVE?=false 18 | 19 | $(LAMBDA_BINARY): $(shell find . -name '*.go') go.mod go.sum 20 | mkdir -p dist 21 | cd cmd/lambda && $(LAMBDA_BUILD) -o ../../dist/bootstrap . 22 | zip -j $(LAMBDA_BINARY) dist/bootstrap 23 | 24 | .PHONY: clean 25 | clean: 26 | rm -rf dist 27 | 28 | TF_INIT_VARS=-backend-config=bucket=$(BUCKET_NAME) \ 29 | -backend-config=key=states/$(APP_NAME)/terraform.tfstate \ 30 | -backend-config=region=$(AWS_REGION) 31 | 32 | TF_VARS=-var name=$(APP_NAME) \ 33 | -var git_revision=$(GIT_REVISION) \ 34 | -var lambda_filename=../../$(LAMBDA_BINARY) \ 35 | -var lambda_handler=burrow 36 | 37 | .PHONY: deploy 38 | deploy: $(LAMBDA_BINARY) 39 | cd terraform/main && \ 40 | terraform init $(TF_INIT_VARS) -reconfigure && \ 41 | terraform apply -auto-approve=$(AUTO_APPROVE) $(TF_VARS) && \ 42 | terraform output -json function_urls | jq > ../../$(APP_NAME)-urls.json 43 | @echo "wrote $(APP_NAME)-urls.json" 44 | 45 | .PHONY: destroy 46 | destroy: $(LAMBDA_BINARY) 47 | cd terraform/main && \ 48 | terraform init $(TF_INIT_VARS) -reconfigure && \ 49 | terraform destroy -auto-approve=$(AUTO_APPROVE) $(TF_VARS) 50 | -------------------------------------------------------------------------------- /terraform/modules/lambda/main.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "aws_lambda_function" "lambda" { 3 | architectures = var.architectures 4 | filename = var.filename 5 | function_name = var.name 6 | description = "${var.name} function" 7 | handler = var.handler 8 | kms_key_arn = var.kms_key_arn 9 | memory_size = var.memory_size 10 | role = var.iam_role_arn 11 | runtime = var.runtime 12 | source_code_hash = filebase64sha256(var.filename) 13 | tags = var.tags 14 | timeout = var.timeout 15 | tracing_config { 16 | mode = "Active" 17 | } 18 | environment { 19 | variables = var.environment 20 | } 21 | depends_on = [ 22 | aws_cloudwatch_log_group.lambda 23 | ] 24 | } 25 | 26 | resource "aws_cloudwatch_log_group" "lambda" { 27 | name = "/aws/lambda/${var.name}" 28 | retention_in_days = var.log_retention 29 | kms_key_id = var.kms_key_arn 30 | tags = var.tags 31 | } 32 | 33 | resource "aws_lambda_function_url" "lambda" { 34 | function_name = aws_lambda_function.lambda.function_name 35 | authorization_type = var.authorization_type 36 | dynamic "cors" { 37 | for_each = var.cors != null ? [var.cors] : [] 38 | content { 39 | allow_credentials = cors.value.allow_credentials 40 | allow_origins = cors.value.allow_origins 41 | allow_methods = cors.value.allow_methods 42 | allow_headers = cors.value.allow_headers 43 | expose_headers = cors.value.expose_headers 44 | max_age = cors.value.max_age 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /terraform/main/variables.tf: -------------------------------------------------------------------------------- 1 | 2 | variable "name" { 3 | description = "Application name" 4 | type = string 5 | } 6 | 7 | variable "regions" { 8 | description = "Deployment regions" 9 | type = list(string) 10 | default = [ 11 | "us-east-1", 12 | "us-west-1", 13 | "us-west-2", 14 | "eu-west-1", 15 | "eu-west-2", 16 | "us-east-2", 17 | "sa-east-1", 18 | ] 19 | } 20 | 21 | variable "tags" { 22 | description = "Tags to apply to all resources" 23 | type = map(string) 24 | default = {} 25 | } 26 | 27 | variable "log_retention" { 28 | description = "Log group retention in days" 29 | type = number 30 | default = 365 31 | } 32 | 33 | variable "git_revision" { 34 | description = "Git revision" 35 | type = string 36 | default = "" 37 | } 38 | 39 | variable "lambda_filename" { 40 | description = "Lambda filename" 41 | type = string 42 | default = "" 43 | } 44 | 45 | variable "lambda_handler" { 46 | description = "Lambda handler" 47 | type = string 48 | default = "" 49 | } 50 | 51 | variable "lambda_architectures" { 52 | description = "Lambda architectures" 53 | type = list(string) 54 | default = ["x86_64"] 55 | } 56 | 57 | variable "lambda_runtime" { 58 | description = "Lambda runtime" 59 | type = string 60 | default = "provided.al2023" 61 | } 62 | 63 | variable "lambda_memory_size" { 64 | description = "Lambda memory size" 65 | type = number 66 | default = 256 67 | } 68 | 69 | variable "lambda_timeout" { 70 | description = "Lambda timeout" 71 | type = number 72 | default = 10 73 | } 74 | -------------------------------------------------------------------------------- /cmd/example_multi_region/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/myzie/burrow" 13 | ) 14 | 15 | func readFunctionURLs(path string) (map[string]string, error) { 16 | functions := make(map[string]string) 17 | file, err := os.Open(path) 18 | if err != nil { 19 | return nil, err 20 | } 21 | defer file.Close() 22 | 23 | decoder := json.NewDecoder(file) 24 | if err := decoder.Decode(&functions); err != nil { 25 | return nil, err 26 | } 27 | return functions, nil 28 | } 29 | 30 | func main() { 31 | var target, functionSpec string 32 | flag.StringVar(&target, "url", "https://api.ipify.org?format=json", "URL to send a request to") 33 | flag.StringVar(&functionSpec, "functions", "./function_urls.json", "Function URLs JSON file") 34 | flag.Parse() 35 | 36 | functions, err := readFunctionURLs(functionSpec) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | var proxyURLs []string 42 | for _, proxyURL := range functions { 43 | proxyURLs = append(proxyURLs, proxyURL) 44 | } 45 | 46 | client := burrow.NewRoundRobinClient(proxyURLs) 47 | 48 | for { 49 | body, err := runRequest(client, target) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | fmt.Println(body) 54 | } 55 | } 56 | 57 | func runRequest(c *http.Client, target string) (string, error) { 58 | req, err := http.NewRequest("GET", target, nil) 59 | if err != nil { 60 | return "", err 61 | } 62 | req.Header.Set("Accept", "application/json") 63 | resp, err := c.Do(req) 64 | if err != nil { 65 | return "", err 66 | } 67 | defer resp.Body.Close() 68 | if resp.StatusCode != http.StatusOK { 69 | return "", fmt.Errorf("non-200 status code: %d", resp.StatusCode) 70 | } 71 | body, err := io.ReadAll(resp.Body) 72 | if err != nil { 73 | return "", err 74 | } 75 | return string(body), nil 76 | } 77 | -------------------------------------------------------------------------------- /terraform/modules/lambda/variables.tf: -------------------------------------------------------------------------------- 1 | 2 | variable "name" { 3 | description = "Function name" 4 | type = string 5 | } 6 | 7 | variable "handler" { 8 | description = "Function entrypoint" 9 | type = string 10 | } 11 | 12 | variable "runtime" { 13 | description = "Function runtime" 14 | type = string 15 | default = "provided.al2023" 16 | } 17 | 18 | variable "kms_key_arn" { 19 | description = "KMS key to use for encryption operations" 20 | type = string 21 | default = null 22 | } 23 | 24 | variable "memory_size" { 25 | description = "Memory size in MB to assign to the function" 26 | type = number 27 | default = 256 28 | } 29 | 30 | variable "timeout" { 31 | description = "Function invocation timeout in seconds" 32 | type = number 33 | default = 10 34 | } 35 | 36 | variable "environment" { 37 | description = "Environment variables" 38 | type = map(string) 39 | default = {} 40 | } 41 | 42 | variable "tags" { 43 | description = "Tags to assign to the function" 44 | type = map(string) 45 | default = {} 46 | } 47 | 48 | variable "filename" { 49 | description = "Path to the function code on the local filesystem" 50 | type = string 51 | } 52 | 53 | variable "architectures" { 54 | description = "List of architectures to build the function for" 55 | type = list(string) 56 | default = ["x86_64"] 57 | } 58 | 59 | variable "iam_role_arn" { 60 | description = "IAM role ARN to assign to the function" 61 | type = string 62 | } 63 | 64 | variable "log_retention" { 65 | description = "Log group retention in days" 66 | type = number 67 | default = 365 68 | } 69 | 70 | variable "authorization_type" { 71 | description = "Authorization type for the function URL" 72 | type = string 73 | default = "NONE" 74 | } 75 | 76 | variable "cors" { 77 | description = "CORS configuration for the function URL" 78 | type = object({ 79 | allow_credentials = bool 80 | allow_origins = list(string) 81 | allow_methods = list(string) 82 | allow_headers = list(string) 83 | expose_headers = list(string) 84 | max_age = number 85 | }) 86 | default = null 87 | } 88 | -------------------------------------------------------------------------------- /cmd/example_client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/myzie/burrow" 15 | ) 16 | 17 | func main() { 18 | var timeoutDur time.Duration 19 | var maxResponseBytes, maxRetries int64 20 | var allowedContentTypes string 21 | var target, proxy, method string 22 | flag.StringVar(&target, "url", "", "URL to send a request to") 23 | flag.StringVar(&proxy, "proxy", "", "URL of the proxy to use") 24 | flag.Int64Var(&maxResponseBytes, "max-response-bytes", 0, "Maximum response body size") 25 | flag.DurationVar(&timeoutDur, "timeout", 0, "Timeout") 26 | flag.Int64Var(&maxRetries, "retries", 0, "Maximum retries") 27 | flag.StringVar(&allowedContentTypes, "allowed-content-types", "", "Allowed content types") 28 | flag.Parse() 29 | 30 | allowedContentTypesList := strings.Split(allowedContentTypes, ",") 31 | 32 | opts := []burrow.ClientOption{ 33 | burrow.WithProxyURL(proxy), 34 | burrow.WithRetries(int(maxRetries)), 35 | burrow.WithRetryableCodes([]int{404}), 36 | burrow.WithCallback(func(ctx context.Context, proxyResponse *burrow.Response) { 37 | fmt.Printf("proxy response: %+v\n", proxyResponse) 38 | }), 39 | } 40 | if maxResponseBytes > 0 { 41 | opts = append(opts, burrow.WithMaxResponseBytes(maxResponseBytes)) 42 | } 43 | if timeoutDur > 0 { 44 | opts = append(opts, burrow.WithTimeout(timeoutDur)) 45 | } 46 | if len(allowedContentTypesList) > 0 { 47 | opts = append(opts, burrow.WithAllowedContentTypes(allowedContentTypesList)) 48 | } 49 | client := burrow.NewClient(opts...) 50 | 51 | req, err := http.NewRequest(method, target, nil) 52 | if err != nil { 53 | fmt.Println("failed to create request:", err) 54 | os.Exit(1) 55 | } 56 | 57 | resp, err := client.Do(req) 58 | if err != nil { 59 | var proxyErr *burrow.ProxyError 60 | if errors.As(err, &proxyErr) { 61 | fmt.Println("proxy error:", proxyErr.Message) 62 | fmt.Println("proxy error type:", proxyErr.Type) 63 | os.Exit(1) 64 | } 65 | fmt.Println("unknown error:", err) 66 | os.Exit(1) 67 | } 68 | defer resp.Body.Close() 69 | 70 | if resp.StatusCode != http.StatusOK { 71 | fmt.Printf("unexpected status code: %d\n", resp.StatusCode) 72 | os.Exit(1) 73 | } 74 | body, err := io.ReadAll(resp.Body) 75 | if err != nil { 76 | fmt.Println("failed to read response body:", err) 77 | os.Exit(1) 78 | } 79 | 80 | fmt.Println("================") 81 | fmt.Println(string(body)) 82 | } 83 | -------------------------------------------------------------------------------- /requests.go: -------------------------------------------------------------------------------- 1 | package burrow 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | // Request represents an http request in a format that can be easily serialized 12 | type Request struct { 13 | URL string `json:"url"` 14 | Method string `json:"method,omitempty"` 15 | Headers map[string]string `json:"headers,omitempty"` 16 | Body string `json:"body,omitempty"` 17 | Cookies string `json:"cookies,omitempty"` 18 | Timeout float64 `json:"timeout,omitempty"` 19 | MaxResponseBytes int64 `json:"max_response_bytes,omitempty"` 20 | AllowedContentTypes []string `json:"allowed_content_types,omitempty"` 21 | } 22 | 23 | // Response represents an http response in a format that can be easily deserialized 24 | type Response struct { 25 | StatusCode int `json:"status_code"` 26 | Headers map[string]string `json:"headers,omitempty"` 27 | Body string `json:"body,omitempty"` 28 | ClientDetails *ClientDetails `json:"client_details,omitempty"` 29 | Duration float64 `json:"duration,omitempty"` 30 | ProxyName string `json:"proxy_name,omitempty"` 31 | } 32 | 33 | // ClientDetails represents the details of the client that made the request 34 | type ClientDetails struct { 35 | SourceIP string `json:"source_ip"` 36 | UserAgent string `json:"user_agent"` 37 | } 38 | 39 | func SerializeRequest(req *http.Request) (*Request, error) { 40 | headers := make(map[string]string) 41 | for k, v := range req.Header { 42 | headers[k] = v[0] 43 | } 44 | var encodedBody string 45 | if req.Body != nil { 46 | body, err := io.ReadAll(req.Body) 47 | if err != nil { 48 | return nil, err 49 | } 50 | req.Body = io.NopCloser(bytes.NewBuffer(body)) 51 | encodedBody = base64.StdEncoding.EncodeToString(body) 52 | } 53 | return &Request{ 54 | Method: req.Method, 55 | URL: req.URL.String(), 56 | Headers: headers, 57 | Body: encodedBody, 58 | }, nil 59 | } 60 | 61 | func DeserializeResponse(serResp *Response) (*http.Response, error) { 62 | var decodedBody []byte 63 | if serResp.Body != "" { 64 | var err error 65 | decodedBody, err = base64.StdEncoding.DecodeString(serResp.Body) 66 | if err != nil { 67 | return nil, fmt.Errorf("failed to decode response body: %w", err) 68 | } 69 | } 70 | resp := &http.Response{ 71 | StatusCode: serResp.StatusCode, 72 | Header: make(http.Header), 73 | Body: io.NopCloser(bytes.NewBuffer(decodedBody)), 74 | } 75 | for k, v := range serResp.Headers { 76 | resp.Header.Set(k, v) 77 | } 78 | return resp, nil 79 | } 80 | -------------------------------------------------------------------------------- /transport_roundrobin.go: -------------------------------------------------------------------------------- 1 | package burrow 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "math" 7 | "net/http" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | var _ http.RoundTripper = &RoundRobinTransport{} 13 | 14 | // RoundRobinTransport is an http.RoundTripper that sends requests using a 15 | // rotating set of http.Transports. 16 | type RoundRobinTransport struct { 17 | transports []http.RoundTripper 18 | mutex sync.Mutex 19 | index int 20 | retries int 21 | retryable map[int]bool 22 | } 23 | 24 | // NewRoundRobinTransport creates a new RoundRobinTransport that rotates through 25 | // the provided http.Transports with each request. 26 | func NewRoundRobinTransport(transports []http.RoundTripper) *RoundRobinTransport { 27 | return &RoundRobinTransport{ 28 | transports: transports, 29 | retryable: defaultRetryableCodes, 30 | } 31 | } 32 | 33 | // WithRetries sets the allowed number of retry attempts for each request. 34 | func (r *RoundRobinTransport) WithRetries(retries int) *RoundRobinTransport { 35 | if retries < 0 { 36 | retries = 0 37 | } 38 | r.retries = retries 39 | return r 40 | } 41 | 42 | // WithRetryableCodes sets the list of HTTP status codes that should be retried. 43 | func (r *RoundRobinTransport) WithRetryableCodes(codes []int) *RoundRobinTransport { 44 | retryable := map[int]bool{} 45 | for _, code := range codes { 46 | retryable[code] = true 47 | } 48 | r.retryable = retryable 49 | return r 50 | } 51 | 52 | // RoundTrip implements the http.RoundTripper interface. 53 | func (r *RoundRobinTransport) RoundTrip(req *http.Request) (*http.Response, error) { 54 | // Clone the request body if it exists 55 | var bodyBytes []byte 56 | if req.Body != nil { 57 | var err error 58 | bodyBytes, err = io.ReadAll(req.Body) 59 | if err != nil { 60 | return nil, err 61 | } 62 | req.Body.Close() 63 | } 64 | var lastResp *http.Response 65 | for i := 0; i <= r.retries; i++ { 66 | // Recreate the body for each attempt 67 | if bodyBytes != nil { 68 | req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) 69 | } 70 | transport := r.nextTransport() 71 | response, err := transport.RoundTrip(req) 72 | if err != nil { 73 | // This means the proxying itself failed, which we will not retry 74 | return nil, err 75 | } 76 | // Return immediately if the status code is in the 2xx range 77 | if response.StatusCode >= 200 && response.StatusCode < 300 { 78 | return response, nil 79 | } 80 | // Return immediately if the status code is not retryable 81 | if !r.isRetryable(response.StatusCode) { 82 | return response, nil 83 | } 84 | // Close the response body if we're not returning it 85 | if lastResp != nil { 86 | lastResp.Body.Close() 87 | } 88 | lastResp = response 89 | 90 | // Skip sleep on the last iteration 91 | if i < r.retries { 92 | // Calculate backoff duration starting from 100ms 93 | backoff := time.Duration(math.Pow(2, float64(i))*100) * time.Millisecond 94 | // Use context-aware sleep 95 | timer := time.NewTimer(backoff) 96 | select { 97 | case <-req.Context().Done(): 98 | timer.Stop() 99 | return lastResp, req.Context().Err() 100 | case <-timer.C: 101 | // Continue with next retry 102 | } 103 | } 104 | } 105 | return lastResp, nil 106 | } 107 | 108 | func (r *RoundRobinTransport) nextTransport() http.RoundTripper { 109 | r.mutex.Lock() 110 | defer r.mutex.Unlock() 111 | transport := r.transports[r.index] 112 | r.index = (r.index + 1) % len(r.transports) 113 | return transport 114 | } 115 | 116 | func (r *RoundRobinTransport) isRetryable(code int) bool { 117 | return r.retryable[code] 118 | } 119 | 120 | var defaultRetryableCodes = map[int]bool{ 121 | http.StatusTooManyRequests: true, 122 | http.StatusRequestTimeout: true, 123 | http.StatusServiceUnavailable: true, 124 | http.StatusGatewayTimeout: true, 125 | http.StatusBadGateway: true, 126 | 999: true, 127 | } 128 | -------------------------------------------------------------------------------- /transport_http_test.go: -------------------------------------------------------------------------------- 1 | package burrow 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestTransport_RoundTrip(t *testing.T) { 17 | successEncoded := base64.StdEncoding.EncodeToString([]byte("success")) 18 | tests := []struct { 19 | name string 20 | setupMockProxy func() *httptest.Server 21 | setupTransport func(*Transport) 22 | inputURL string 23 | expectedErr string 24 | }{ 25 | { 26 | name: "successful request", 27 | setupMockProxy: func() *httptest.Server { 28 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | assert.Equal(t, "application/json", r.Header.Get("Content-Type")) 30 | json.NewEncoder(w).Encode(Response{ 31 | StatusCode: 200, 32 | Headers: map[string]string{"Content-Type": "text/plain"}, 33 | Body: successEncoded, 34 | }) 35 | })) 36 | }, 37 | inputURL: "https://example.com", 38 | }, 39 | { 40 | name: "proxy returns error", 41 | setupMockProxy: func() *httptest.Server { 42 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | w.WriteHeader(http.StatusBadRequest) 44 | json.NewEncoder(w).Encode(ProxyError{ 45 | Message: "bad request", 46 | Type: ProxyErrBadRequest, 47 | }) 48 | })) 49 | }, 50 | inputURL: "https://example.com", 51 | expectedErr: "proxy error [1] bad request", 52 | }, 53 | { 54 | name: "respects timeout", 55 | setupMockProxy: func() *httptest.Server { 56 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | // Decode the incoming request to verify timeout was set 58 | var serReq Request 59 | json.NewDecoder(r.Body).Decode(&serReq) 60 | assert.Equal(t, float64(2), serReq.Timeout) 61 | resp := Response{ 62 | StatusCode: 200, 63 | Body: successEncoded, 64 | } 65 | json.NewEncoder(w).Encode(resp) 66 | })) 67 | }, 68 | setupTransport: func(t *Transport) { 69 | t.WithTimeout(2 * time.Second) 70 | }, 71 | inputURL: "https://example.com", 72 | }, 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | mockProxy := tt.setupMockProxy() 78 | defer mockProxy.Close() 79 | 80 | transport := NewTransport(mockProxy.URL, "POST") 81 | if tt.setupTransport != nil { 82 | tt.setupTransport(transport) 83 | } 84 | req, err := http.NewRequest("GET", tt.inputURL, nil) 85 | require.NoError(t, err) 86 | resp, err := transport.RoundTrip(req) 87 | if tt.expectedErr != "" { 88 | assert.EqualError(t, err, tt.expectedErr) 89 | assert.Nil(t, resp) 90 | } else { 91 | assert.NoError(t, err) 92 | assert.NotNil(t, resp) 93 | } 94 | }) 95 | } 96 | } 97 | 98 | func TestTransportBuilders(t *testing.T) { 99 | transport := NewTransport("http://proxy", "POST") 100 | 101 | callback := func(ctx context.Context, r *Response) {} 102 | transport.WithCallback(callback) 103 | assert.NotNil(t, transport.callback) 104 | 105 | transport.WithTimeout(5 * time.Second) 106 | assert.Equal(t, 5*time.Second, transport.timeout) 107 | 108 | transport.WithMaxResponseBytes(1000) 109 | assert.Equal(t, int64(1000), transport.maxResponseBytes) 110 | 111 | allowedTypes := []string{"application/json"} 112 | transport.WithAllowedContentTypes(allowedTypes) 113 | assert.Equal(t, allowedTypes, transport.allowedContentTypes) 114 | } 115 | 116 | func TestNewTransportWithClient(t *testing.T) { 117 | customClient := &http.Client{Timeout: 5 * time.Second} 118 | transport := NewTransportWithClient("http://proxy", "POST", customClient) 119 | 120 | assert.Equal(t, customClient, transport.client) 121 | assert.Equal(t, "http://proxy", transport.proxyURL) 122 | assert.Equal(t, "POST", transport.method) 123 | } 124 | -------------------------------------------------------------------------------- /cmd/lambda/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "os" 10 | 11 | "github.com/aws/aws-lambda-go/events" 12 | "github.com/aws/aws-lambda-go/lambda" 13 | "github.com/myzie/burrow" 14 | ) 15 | 16 | type RequestHandler struct { 17 | Burrow burrow.Handler 18 | Logger *slog.Logger 19 | } 20 | 21 | func (h RequestHandler) Handle(ctx context.Context, request events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { 22 | var burrowReq burrow.Request 23 | if err := json.Unmarshal([]byte(request.Body), &burrowReq); err != nil { 24 | return NewGenericErrorResponse(400, fmt.Errorf("invalid request body (expected json)")), nil 25 | } 26 | if burrowReq.Method == "" { 27 | burrowReq.Method = "GET" 28 | } 29 | proxyName := fmt.Sprintf("aws.lambda.%s", getRegion()) 30 | 31 | h.Logger.Info("request received", 32 | "proxy_name", proxyName, 33 | "url", burrowReq.URL, 34 | "method", burrowReq.Method, 35 | "timeout", burrowReq.Timeout, 36 | "max_response_bytes", burrowReq.MaxResponseBytes, 37 | "allowed_content_types", burrowReq.AllowedContentTypes, 38 | "client_ip", request.RequestContext.HTTP.SourceIP, 39 | "user_agent", request.RequestContext.HTTP.UserAgent) 40 | 41 | response, err := h.Burrow(ctx, &burrowReq) 42 | if err != nil { 43 | var proxyErr *burrow.ProxyError 44 | if errors.As(err, &proxyErr) { 45 | h.Logger.Error("proxy error", "error", err) 46 | return NewProxyErrorResponse(proxyErr), nil 47 | } 48 | h.Logger.Error("unknown error", "error", err) 49 | return NewGenericErrorResponse(500, err), nil 50 | } 51 | 52 | response.ClientDetails = &burrow.ClientDetails{ 53 | SourceIP: request.RequestContext.HTTP.SourceIP, 54 | UserAgent: request.RequestContext.HTTP.UserAgent, 55 | } 56 | response.ProxyName = proxyName 57 | responseBody, err := json.Marshal(response) 58 | if err != nil { 59 | h.Logger.Error("marshalling error", "error", err) 60 | return NewGenericErrorResponse(500, err), nil 61 | } 62 | 63 | h.Logger.Info("request completed", 64 | "proxy_name", proxyName, 65 | "url", burrowReq.URL, 66 | "method", burrowReq.Method, 67 | "duration", response.Duration, 68 | "status_code", response.StatusCode, 69 | "body_size", len(responseBody), 70 | "content_type", response.Headers["Content-Type"]) 71 | 72 | return events.APIGatewayV2HTTPResponse{ 73 | StatusCode: 200, 74 | Body: string(responseBody), 75 | Headers: map[string]string{"Content-Type": "application/json"}, 76 | }, nil 77 | } 78 | 79 | func NewGenericErrorResponse(statusCode int, err error) events.APIGatewayV2HTTPResponse { 80 | body, err := json.Marshal(map[string]string{"message": err.Error()}) 81 | if err != nil { 82 | return events.APIGatewayV2HTTPResponse{ 83 | StatusCode: statusCode, 84 | Body: err.Error(), 85 | Headers: map[string]string{"Content-Type": "text/plain"}, 86 | } 87 | } 88 | return events.APIGatewayV2HTTPResponse{ 89 | StatusCode: statusCode, 90 | Body: string(body), 91 | Headers: map[string]string{"Content-Type": "application/json"}, 92 | } 93 | } 94 | 95 | func NewProxyErrorResponse(proxyErr *burrow.ProxyError) events.APIGatewayV2HTTPResponse { 96 | statusCode := 500 97 | if proxyErr.Type == burrow.ProxyErrBadRequest { 98 | statusCode = 400 99 | } 100 | proxyErrBody, err := json.Marshal(proxyErr) 101 | if err != nil { 102 | return events.APIGatewayV2HTTPResponse{ 103 | StatusCode: statusCode, 104 | Body: fmt.Sprintf(`{"message": "marshalling error: %s"}`, err.Error()), 105 | Headers: map[string]string{"Content-Type": "application/json"}, 106 | } 107 | } 108 | return events.APIGatewayV2HTTPResponse{ 109 | StatusCode: statusCode, 110 | Body: string(proxyErrBody), 111 | Headers: map[string]string{"Content-Type": "application/json"}, 112 | } 113 | } 114 | 115 | func getRegion() string { 116 | if region := os.Getenv("AWS_REGION"); region != "" { 117 | return region 118 | } 119 | return os.Getenv("AWS_DEFAULT_REGION") 120 | } 121 | 122 | func main() { 123 | h := RequestHandler{ 124 | Burrow: burrow.GetHandler(), 125 | Logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), 126 | } 127 | lambda.Start(h.Handle) 128 | } 129 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package burrow 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | // NewRoundRobinClient is a convenience function for creating an http.Client 9 | // with a RoundRobinTransport that rotates through the provided proxy URLs. 10 | func NewRoundRobinClient(proxyURLs []string) *http.Client { 11 | if len(proxyURLs) == 0 { 12 | return &http.Client{} 13 | } 14 | var transports []http.RoundTripper 15 | for _, proxyURL := range proxyURLs { 16 | transports = append(transports, NewTransport(proxyURL, "POST")) 17 | } 18 | return &http.Client{ 19 | Transport: NewRoundRobinTransport(transports), 20 | } 21 | } 22 | 23 | // ClientOption defines a function that configures a client 24 | type ClientOption func(*clientConfig) 25 | 26 | type clientConfig struct { 27 | proxyURLs []string 28 | retries int 29 | retryableCodes []int 30 | callback ProxyCallback 31 | timeout time.Duration 32 | maxResponseBytes int64 33 | allowedContentTypes []string 34 | } 35 | 36 | // WithProxyURL sets a single proxy URL for the client 37 | func WithProxyURL(url string) ClientOption { 38 | return func(c *clientConfig) { 39 | c.proxyURLs = []string{url} 40 | } 41 | } 42 | 43 | // WithProxyURLs sets the proxy URLs for the client 44 | func WithProxyURLs(urls []string) ClientOption { 45 | return func(c *clientConfig) { 46 | c.proxyURLs = urls 47 | } 48 | } 49 | 50 | // WithRetries sets the number of retries for the client 51 | func WithRetries(retries int) ClientOption { 52 | return func(c *clientConfig) { 53 | c.retries = retries 54 | } 55 | } 56 | 57 | // WithRetryableCodes sets the HTTP status codes that should trigger a retry 58 | func WithRetryableCodes(codes []int) ClientOption { 59 | return func(c *clientConfig) { 60 | c.retryableCodes = codes 61 | } 62 | } 63 | 64 | // WithCallback sets a callback function that will be called each time a proxy 65 | // request has completed successfully. 66 | func WithCallback(callback ProxyCallback) ClientOption { 67 | return func(c *clientConfig) { 68 | c.callback = callback 69 | } 70 | } 71 | 72 | // WithTimeout sets the timeout that is passed to the proxy 73 | func WithTimeout(timeout time.Duration) ClientOption { 74 | return func(c *clientConfig) { 75 | c.timeout = timeout 76 | } 77 | } 78 | 79 | // WithMaxResponseBytes sets the maximum response body size 80 | func WithMaxResponseBytes(maxResponseBytes int64) ClientOption { 81 | return func(c *clientConfig) { 82 | c.maxResponseBytes = maxResponseBytes 83 | } 84 | } 85 | 86 | // WithAllowedContentTypes sets the allowed content types 87 | func WithAllowedContentTypes(allowedContentTypes []string) ClientOption { 88 | return func(c *clientConfig) { 89 | c.allowedContentTypes = allowedContentTypes 90 | } 91 | } 92 | 93 | // NewClient creates an http.Client with the provided Burrow options. 94 | // If no proxy URLs are provided, a vanilla http.Client is returned. 95 | func NewClient(opts ...ClientOption) *http.Client { 96 | return &http.Client{Transport: NewTransportWithOptions(opts...)} 97 | } 98 | 99 | // NewTransportWithOptions creates a new http.RoundTripper with the provided 100 | // Burrow options. If no proxy URLs are provided, the default HTTP transport is 101 | // returned. 102 | func NewTransportWithOptions(opts ...ClientOption) http.RoundTripper { 103 | cfg := &clientConfig{} 104 | for _, opt := range opts { 105 | opt(cfg) 106 | } 107 | if len(cfg.proxyURLs) == 0 { 108 | return http.DefaultTransport 109 | } 110 | var transports []http.RoundTripper 111 | for _, proxyURL := range cfg.proxyURLs { 112 | transport := NewTransport(proxyURL, "POST") 113 | if cfg.callback != nil { 114 | transport.WithCallback(cfg.callback) 115 | } 116 | if cfg.timeout > 0 { 117 | transport.WithTimeout(cfg.timeout) 118 | } 119 | if cfg.maxResponseBytes > 0 { 120 | transport.WithMaxResponseBytes(cfg.maxResponseBytes) 121 | } 122 | if len(cfg.allowedContentTypes) > 0 { 123 | transport.WithAllowedContentTypes(cfg.allowedContentTypes) 124 | } 125 | transports = append(transports, transport) 126 | } 127 | rr := NewRoundRobinTransport(transports) 128 | if cfg.retries > 0 { 129 | rr.WithRetries(cfg.retries) 130 | } 131 | if cfg.retryableCodes != nil { 132 | rr.WithRetryableCodes(cfg.retryableCodes) 133 | } 134 | return rr 135 | } 136 | -------------------------------------------------------------------------------- /terraform/main/providers.tf: -------------------------------------------------------------------------------- 1 | 2 | // Virginia 3 | provider "aws" { 4 | region = "us-east-1" 5 | alias = "us-east-1" 6 | skip_metadata_api_check = false 7 | skip_region_validation = false 8 | skip_credentials_validation = false 9 | } 10 | 11 | // California 12 | provider "aws" { 13 | region = "us-west-1" 14 | alias = "us-west-1" 15 | skip_metadata_api_check = true 16 | skip_region_validation = true 17 | skip_credentials_validation = true 18 | } 19 | 20 | // Oregon 21 | provider "aws" { 22 | region = "us-west-2" 23 | alias = "us-west-2" 24 | skip_metadata_api_check = true 25 | skip_region_validation = true 26 | skip_credentials_validation = true 27 | } 28 | 29 | // Dublin 30 | provider "aws" { 31 | region = "eu-west-1" 32 | alias = "eu-west-1" 33 | skip_metadata_api_check = true 34 | skip_region_validation = true 35 | skip_credentials_validation = true 36 | } 37 | 38 | // London 39 | provider "aws" { 40 | region = "eu-west-2" 41 | alias = "eu-west-2" 42 | skip_metadata_api_check = true 43 | skip_region_validation = true 44 | skip_credentials_validation = true 45 | } 46 | 47 | // Ohio 48 | provider "aws" { 49 | region = "us-east-2" 50 | alias = "us-east-2" 51 | skip_metadata_api_check = true 52 | skip_region_validation = true 53 | skip_credentials_validation = true 54 | } 55 | 56 | // Sao Paulo 57 | provider "aws" { 58 | region = "sa-east-1" 59 | alias = "sa-east-1" 60 | skip_metadata_api_check = true 61 | skip_region_validation = true 62 | skip_credentials_validation = true 63 | } 64 | 65 | // Frankfurt 66 | provider "aws" { 67 | region = "eu-central-1" 68 | alias = "eu-central-1" 69 | skip_metadata_api_check = true 70 | skip_region_validation = true 71 | skip_credentials_validation = true 72 | } 73 | 74 | // Paris 75 | provider "aws" { 76 | region = "eu-west-3" 77 | alias = "eu-west-3" 78 | skip_metadata_api_check = true 79 | skip_region_validation = true 80 | skip_credentials_validation = true 81 | } 82 | 83 | // Stockholm 84 | provider "aws" { 85 | region = "eu-north-1" 86 | alias = "eu-north-1" 87 | skip_metadata_api_check = true 88 | skip_region_validation = true 89 | skip_credentials_validation = true 90 | } 91 | 92 | // Canada 93 | provider "aws" { 94 | region = "ca-central-1" 95 | alias = "ca-central-1" 96 | skip_metadata_api_check = true 97 | skip_region_validation = true 98 | skip_credentials_validation = true 99 | } 100 | 101 | // Seoul 102 | provider "aws" { 103 | region = "ap-northeast-2" 104 | alias = "ap-northeast-2" 105 | skip_metadata_api_check = true 106 | skip_region_validation = true 107 | skip_credentials_validation = true 108 | } 109 | 110 | // Mumbai 111 | provider "aws" { 112 | region = "ap-south-1" 113 | alias = "ap-south-1" 114 | skip_metadata_api_check = true 115 | skip_region_validation = true 116 | skip_credentials_validation = true 117 | } 118 | 119 | // Singapore 120 | provider "aws" { 121 | region = "ap-southeast-1" 122 | alias = "ap-southeast-1" 123 | skip_metadata_api_check = true 124 | skip_region_validation = true 125 | skip_credentials_validation = true 126 | } 127 | 128 | // Sydney 129 | provider "aws" { 130 | region = "ap-southeast-2" 131 | alias = "ap-southeast-2" 132 | skip_metadata_api_check = true 133 | skip_region_validation = true 134 | skip_credentials_validation = true 135 | } 136 | 137 | // Tokyo 138 | provider "aws" { 139 | region = "ap-northeast-1" 140 | alias = "ap-northeast-1" 141 | skip_metadata_api_check = true 142 | skip_region_validation = true 143 | skip_credentials_validation = true 144 | } 145 | 146 | // Osaka 147 | provider "aws" { 148 | region = "ap-northeast-3" 149 | alias = "ap-northeast-3" 150 | skip_metadata_api_check = true 151 | skip_region_validation = true 152 | skip_credentials_validation = true 153 | } 154 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package burrow 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | var defaultMaxRedirects = 5 15 | 16 | var defaultMaxResponseBytes = int64(5 * 1024 * 1024) // 5MB default 17 | 18 | var defaultTransport = &http.Transport{ 19 | DialContext: (&net.Dialer{ 20 | Timeout: 5 * time.Second, 21 | KeepAlive: 30 * time.Second, 22 | }).DialContext, 23 | TLSHandshakeTimeout: 5 * time.Second, 24 | ResponseHeaderTimeout: 10 * time.Second, 25 | MaxIdleConns: 100, 26 | MaxIdleConnsPerHost: 10, 27 | IdleConnTimeout: 90 * time.Second, 28 | } 29 | 30 | // Handler is a function used to process Burrow HTTP proxy requests. 31 | type Handler func(ctx context.Context, req *Request) (*Response, error) 32 | 33 | // GetHandler returns a Handler that proxies HTTP requests. 34 | func GetHandler(c ...*http.Client) Handler { 35 | var client *http.Client 36 | if len(c) > 0 { 37 | client = c[0] 38 | } else { 39 | client = &http.Client{ 40 | Transport: defaultTransport, 41 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 42 | if len(via) >= defaultMaxRedirects { 43 | return fmt.Errorf("stopped after %d redirects", len(via)) 44 | } 45 | return nil 46 | }, 47 | } 48 | } 49 | return func(ctx context.Context, req *Request) (*Response, error) { 50 | if req.URL == "" { 51 | return nil, ProxyErrorf(ProxyErrBadRequest, "url is required") 52 | } 53 | start := time.Now() 54 | if req.Timeout > 0 { 55 | var cancel context.CancelFunc 56 | ctx, cancel = context.WithTimeout(ctx, time.Duration(req.Timeout*float64(time.Second))) 57 | defer cancel() 58 | } 59 | method := "GET" 60 | if req.Method != "" { 61 | method = req.Method 62 | } 63 | var decodedBody []byte 64 | if req.Body != "" { 65 | var err error 66 | decodedBody, err = base64.StdEncoding.DecodeString(req.Body) 67 | if err != nil { 68 | return nil, ProxyErrorf(ProxyErrBadRequest, "failed to decode request body: %v", err) 69 | } 70 | } 71 | httpReqBody := strings.NewReader(string(decodedBody)) 72 | httpReq, err := http.NewRequestWithContext(ctx, method, req.URL, httpReqBody) 73 | if err != nil { 74 | return nil, ProxyErrorf(ProxyErrBadRequest, "failed to create http request: %v", err) 75 | } 76 | for k, v := range req.Headers { 77 | httpReq.Header.Set(k, v) 78 | } 79 | if req.Cookies != "" { 80 | httpReq.Header.Add("Cookie", req.Cookies) 81 | } 82 | resp, err := client.Do(httpReq) 83 | if err != nil { 84 | if isTimeoutError(err) { 85 | return nil, ProxyErrorf(ProxyErrTimeout, "http request timed out") 86 | } 87 | return nil, ProxyErrorf(ProxyErrUnknown, "failed to execute http request: %v", err) 88 | } 89 | defer resp.Body.Close() 90 | 91 | if len(req.AllowedContentTypes) > 0 { 92 | contentType := resp.Header.Get("Content-Type") 93 | if !isContentTypeAllowed(contentType, req.AllowedContentTypes) { 94 | return nil, ProxyErrorf(ProxyErrDisallowedContentType, "response content type is disallowed: %s", contentType) 95 | } 96 | } 97 | maxSize := defaultMaxResponseBytes 98 | if req.MaxResponseBytes > 0 { 99 | maxSize = req.MaxResponseBytes 100 | } 101 | // Add 1 so that we can detect if the body was truncated 102 | limitReader := io.LimitReader(resp.Body, maxSize+1) 103 | body, err := io.ReadAll(limitReader) 104 | if err != nil { 105 | if isTimeoutError(err) { 106 | return nil, ProxyErrorf(ProxyErrTimeout, "response body read timed out") 107 | } 108 | return nil, ProxyErrorf(ProxyErrUnknown, "failed to read response body: %v", err) 109 | } 110 | if int64(len(body)) > maxSize { 111 | return nil, ProxyErrorf(ProxyErrExceededMaxBodySize, "response body exceeded maximum size: %d", maxSize) 112 | } 113 | var encodedBody string 114 | if len(body) > 0 { 115 | encodedBody = base64.StdEncoding.EncodeToString(body) 116 | } 117 | headers := map[string]string{} 118 | for k, v := range resp.Header { 119 | headers[k] = v[0] 120 | } 121 | return &Response{ 122 | StatusCode: resp.StatusCode, 123 | Headers: headers, 124 | Body: encodedBody, 125 | Duration: time.Since(start).Seconds(), 126 | }, nil 127 | } 128 | } 129 | 130 | func isContentTypeAllowed(contentType string, allowedContentTypes []string) bool { 131 | for _, allowedContentType := range allowedContentTypes { 132 | if strings.HasPrefix(contentType, allowedContentType) { 133 | return true 134 | } 135 | } 136 | return false 137 | } 138 | 139 | func isTimeoutError(err error) bool { 140 | if netErr, ok := err.(net.Error); ok { 141 | return netErr.Timeout() 142 | } 143 | if err == context.DeadlineExceeded { 144 | return true 145 | } 146 | return false 147 | } 148 | -------------------------------------------------------------------------------- /transport_http.go: -------------------------------------------------------------------------------- 1 | package burrow 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | var _ http.RoundTripper = &Transport{} 14 | 15 | type ErrorCode int 16 | 17 | const ( 18 | ProxyErrUnknown ErrorCode = 0 19 | ProxyErrBadRequest ErrorCode = 1 20 | ProxyErrExceededMaxBodySize ErrorCode = 2 21 | ProxyErrDisallowedContentType ErrorCode = 3 22 | ProxyErrTimeout ErrorCode = 4 23 | ) 24 | 25 | type ProxyError struct { 26 | Message string `json:"message"` 27 | Type ErrorCode `json:"type"` 28 | } 29 | 30 | func (e *ProxyError) Error() string { 31 | return fmt.Sprintf("proxy error [%d] %s", e.Type, e.Message) 32 | } 33 | 34 | func ProxyErrorf(code ErrorCode, format string, args ...any) *ProxyError { 35 | return &ProxyError{ 36 | Type: code, 37 | Message: fmt.Sprintf(format, args...), 38 | } 39 | } 40 | 41 | // ProxyCallback is a callback function that will be called each time a proxy 42 | // request has completed successfully. 43 | type ProxyCallback func(ctx context.Context, proxyResponse *Response) 44 | 45 | // Transport implements the http.RoundTripper interface. Used to proxy HTTP 46 | // requests via a Burrow HTTP endpoint. 47 | type Transport struct { 48 | proxyURL string 49 | method string 50 | client *http.Client 51 | callback ProxyCallback 52 | timeout time.Duration 53 | maxResponseBytes int64 54 | allowedContentTypes []string 55 | } 56 | 57 | // RoundTrip implements the http.RoundTripper interface 58 | func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { 59 | serReq, err := SerializeRequest(req) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to serialize request: %w", err) 62 | } 63 | serReq.Timeout = t.timeout.Seconds() 64 | serReq.MaxResponseBytes = t.maxResponseBytes 65 | serReq.AllowedContentTypes = t.allowedContentTypes 66 | payload, err := json.Marshal(serReq) 67 | if err != nil { 68 | return nil, fmt.Errorf("failed to marshal request: %w", err) 69 | } 70 | proxyReq, err := http.NewRequestWithContext(req.Context(), t.method, t.proxyURL, bytes.NewReader(payload)) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to create proxy request: %w", err) 73 | } 74 | proxyReq.Header.Set("Content-Type", "application/json") 75 | proxyResp, err := t.client.Do(proxyReq) 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to send request to proxy: %w", err) 78 | } 79 | defer proxyResp.Body.Close() 80 | body, err := io.ReadAll(proxyResp.Body) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to read proxy response body: %w", err) 83 | } 84 | if proxyResp.StatusCode != http.StatusOK { 85 | var errResp ProxyError 86 | if err := json.Unmarshal(body, &errResp); err != nil { 87 | return nil, &ProxyError{ 88 | Message: fmt.Sprintf("proxy returned non-200 status code: %d", proxyResp.StatusCode), 89 | Type: ProxyErrUnknown, 90 | } 91 | } 92 | return nil, &errResp 93 | } 94 | var serResp Response 95 | if err := json.Unmarshal(body, &serResp); err != nil { 96 | return nil, fmt.Errorf("failed to unmarshal response: %w", err) 97 | } 98 | if t.callback != nil { 99 | t.callback(req.Context(), &serResp) 100 | } 101 | return DeserializeResponse(&serResp) 102 | } 103 | 104 | // NewTransport creates a new Transport 105 | func NewTransport(proxyURL string, method string, c ...*http.Client) *Transport { 106 | return &Transport{ 107 | proxyURL: proxyURL, 108 | method: "POST", 109 | client: &http.Client{}, 110 | } 111 | } 112 | 113 | // NewTransportWithClient creates a new Transport that uses the provided 114 | // HTTP client internally. If you're not sure, use NewTransport instead. 115 | func NewTransportWithClient(proxyURL string, method string, c *http.Client) *Transport { 116 | return &Transport{ 117 | proxyURL: proxyURL, 118 | method: method, 119 | client: c, 120 | } 121 | } 122 | 123 | // WithCallback sets a callback function that will be called each time a proxy 124 | // request has completed successfully. 125 | func (t *Transport) WithCallback(callback ProxyCallback) *Transport { 126 | t.callback = callback 127 | return t 128 | } 129 | 130 | // WithTimeout sets the timeout that is passed to the proxy 131 | func (t *Transport) WithTimeout(timeout time.Duration) *Transport { 132 | t.timeout = timeout 133 | return t 134 | } 135 | 136 | // WithMaxResponseBytes sets the maximum response body size 137 | func (t *Transport) WithMaxResponseBytes(maxResponseBytes int64) *Transport { 138 | t.maxResponseBytes = maxResponseBytes 139 | return t 140 | } 141 | 142 | // WithAllowedContentTypes sets the allowed content types 143 | func (t *Transport) WithAllowedContentTypes(allowedContentTypes []string) *Transport { 144 | t.allowedContentTypes = allowedContentTypes 145 | return t 146 | } 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Burrow 2 | 3 | Burrow is a serverless and globally-distributed HTTP proxy for Go built on 4 | AWS Lambda. 5 | 6 | It is designed to be completely compatible with the standard Go `*http.Client` 7 | which means it can be transparently added to many existing applications. Burrow 8 | provides an implementation of the `http.RoundTripper` interface that proxies 9 | requests through one or more AWS Lambda functions exposed with 10 | [Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html). 11 | 12 | A round-robin transport is also provided which makes it trivial to automatically 13 | rotate through multiple Lambda functions in different regions. 14 | 15 | ## Rationale 16 | 17 | Burrow gives you a network of rotating IPs in different regions, which can be 18 | useful in a variety of situations, including: 19 | 20 | - Development: test how your app behaves when accessed from different regions. 21 | - Load testing: simulate distributed global traffic for your services. 22 | - Privacy: anonymous IP addresses when making web requests. 23 | - Geo-restriction bypass: access region-limited content or services. 24 | - API rate limiting: reduce the effects of IP address usage quotas when calling APIs. 25 | - Web scraping: efficiently collect data in a distributed manner. 26 | - Multi-region testing: Verify application behavior across different global regions. 27 | 28 | Performance for individual requests through Burrow can be slow, especially when 29 | routing through distant regions. However, Burrow is designed for highly concurrent 30 | use in Go, where overall throughput matters more than individual request latency. 31 | 32 | ## Features 33 | 34 | - Easy-to-use proxy via `http.RoundTripper` implementation 35 | - Optional round-robin transport for rotating through multiple lambda proxies 36 | - Terraform for one command deployment to 17 AWS regions 37 | 38 | ## Usage 39 | 40 | Add burrow package to your Go project: 41 | 42 | ```bash 43 | go get github.com/myzie/burrow 44 | ``` 45 | 46 | Manually enable on an `http.Client`: 47 | 48 | ```go 49 | proxy := "https://randomprefix.lambda-url.eu-west-2.on.aws/" 50 | client := &http.Client{Transport: burrow.NewTransport(proxy, "POST")} 51 | // Now use the *http.Client as you would normally 52 | ``` 53 | 54 | Create a round-robin transport: 55 | 56 | ```go 57 | proxies := []string{ 58 | "https://randomprefix1.lambda-url.us-east-1.on.aws/", 59 | "https://randomprefix2.lambda-url.us-east-2.on.aws/", 60 | "https://randomprefix3.lambda-url.us-west-1.on.aws/", 61 | } 62 | var transports []http.RoundTripper 63 | for _, u := range proxies { 64 | transports = append(transports, burrow.NewTransport(u, "POST")) 65 | } 66 | client := &http.Client{ 67 | Transport: burrow.NewRoundRobinTransport(transports), 68 | } 69 | // Client will now rotate through the provided proxies for each request 70 | ``` 71 | 72 | Or use the `burrow.NewClient` helper (recommended): 73 | 74 | ```go 75 | client := burrow.NewClient( 76 | burrow.WithProxyURLs([]string{ 77 | "https://randomprefix1.lambda-url.us-east-1.on.aws/", 78 | "https://randomprefix2.lambda-url.us-east-2.on.aws/", 79 | "https://randomprefix3.lambda-url.us-west-1.on.aws/", 80 | }), 81 | burrow.WithRetries(3), 82 | burrow.WithRetryableCodes([]int{429}), 83 | burrow.WithTimeout(30 * time.Second), 84 | burrow.WithMaxResponseBytes(10 * 1024 * 1024), // 10 MB 85 | burrow.WithAllowedContentTypes([]string{ 86 | "application/json", 87 | "text/plain", 88 | }), 89 | burrow.WithCallback(func(ctx context.Context, proxyResponse *burrow.Response) { 90 | log.Printf("request proxied") 91 | }), 92 | ) 93 | ``` 94 | 95 | ## Multi-Region Deployment in AWS 96 | 97 | Burrow includes Terraform configurations to deploy Burrow across the 17 98 | default-enabled AWS regions in your account with a single command: 99 | 100 | ```bash 101 | make deploy BUCKET_NAME=my-terraform-state-bucket 102 | ``` 103 | 104 | When the command completes, a `function_urls.json` file is written which contains 105 | the URL for each Lambda function in each region. You can then read this file in 106 | your Go program and pass the values to `burrow.NewRoundRobinClient`. 107 | 108 | See the Makefile for more information. You'll need the following installed: 109 | 110 | - terraform 111 | - make 112 | - go 113 | - jq 114 | - aws cli 115 | 116 | The default-enabled AWS regions: 117 | 118 | ``` 119 | ap-northeast-1 - Tokyo 120 | ap-northeast-2 - Seoul 121 | ap-northeast-3 - Osaka 122 | ap-south-1 - Mumbai 123 | ap-southeast-1 - Singapore 124 | ap-southeast-2 - Sydney 125 | ca-central-1 - Canada Central 126 | eu-central-1 - Frankfurt 127 | eu-north-1 - Stockholm 128 | eu-west-1 - Ireland 129 | eu-west-2 - London 130 | eu-west-3 - Paris 131 | sa-east-1 - Sao Paulo 132 | us-east-1 - N. Virginia 133 | us-east-2 - Ohio 134 | us-west-1 - N. California 135 | us-west-2 - Oregon 136 | ``` 137 | 138 | ## Future Enhancements 139 | 140 | - Optional API key authentication in the Lambda proxy 141 | - Tests 142 | - Other suggestions? 143 | 144 | ## Examples 145 | 146 | - [cmd/example_client/main.go](cmd/example_client/main.go) 147 | - [cmd/example_multi_region/main.go](cmd/example_multi_region/main.go) 148 | 149 | The multi-region example makes requests to `https://api.ipify.org?format=json` 150 | to demonstrate how the proxy IP address changes across regions. 151 | 152 | ```bash 153 | $ go run ./cmd/example_multi_region 154 | {"ip":"13.36.171.187"} 155 | {"ip":"13.208.187.84"} 156 | {"ip":"18.142.184.58"} 157 | {"ip":"3.106.212.219"} 158 | {"ip":"13.60.11.169"} 159 | {"ip":"43.200.183.53"} 160 | {"ip":"3.127.170.76"} 161 | {"ip":"3.238.225.252"} 162 | {"ip":"54.185.130.119"} 163 | {"ip":"54.168.55.243"} 164 | {"ip":"13.201.18.225"} 165 | {"ip":"15.222.11.134"} 166 | {"ip":"54.247.221.168"} 167 | {"ip":"13.40.174.185"} 168 | {"ip":"15.228.175.92"} 169 | {"ip":"3.137.163.176"} 170 | {"ip":"54.177.165.5"} 171 | {"ip":"13.36.171.187"} # Back to the first region 172 | ``` 173 | 174 | ## Custom Development and Consulting 175 | 176 | The author of Burrow [@myzie](https://github.com/myzie) is available for 177 | contract work and consulting. Feel free to reach out on Github or Linkedin for 178 | help with anything related to Go, AWS, Terraform, cloud security, or SaaS 179 | development. See my [profile](https://github.com/myzie) for my Linkedin. 180 | 181 | ## Contributing 182 | 183 | Contributions are welcome! Please feel free to submit a pull request. 184 | 185 | ## License 186 | 187 | Apache License 2.0 188 | -------------------------------------------------------------------------------- /terraform/main/main.tf: -------------------------------------------------------------------------------- 1 | 2 | data "aws_iam_policy_document" "assume_role" { 3 | statement { 4 | effect = "Allow" 5 | actions = ["sts:AssumeRole"] 6 | principals { 7 | type = "Service" 8 | identifiers = ["lambda.amazonaws.com"] 9 | } 10 | } 11 | } 12 | 13 | resource "aws_iam_role" "this" { 14 | name = "${var.name}-role" 15 | description = "${var.name}-role" 16 | assume_role_policy = data.aws_iam_policy_document.assume_role.json 17 | } 18 | 19 | resource "aws_iam_role_policy_attachment" "xray_access" { 20 | role = aws_iam_role.this.name 21 | policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" 22 | } 23 | 24 | resource "aws_iam_role_policy_attachment" "logs_access" { 25 | role = aws_iam_role.this.name 26 | policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 27 | } 28 | 29 | // Unfortunately, the lack of dynamic providers in Terraform means we have to 30 | // manually define each region. If you needed this to be dynamic, you would 31 | // want to generate this code instead. 32 | 33 | // Virginia 34 | module "region-us-east-1" { 35 | providers = { aws = aws.us-east-1 } 36 | source = "../modules/lambda" 37 | log_retention = var.log_retention 38 | name = var.name 39 | tags = var.tags 40 | filename = var.lambda_filename 41 | handler = var.lambda_handler 42 | runtime = var.lambda_runtime 43 | architectures = var.lambda_architectures 44 | iam_role_arn = aws_iam_role.this.arn 45 | } 46 | 47 | // California 48 | module "region-us-west-1" { 49 | providers = { aws = aws.us-west-1 } 50 | source = "../modules/lambda" 51 | log_retention = var.log_retention 52 | name = var.name 53 | tags = var.tags 54 | filename = var.lambda_filename 55 | handler = var.lambda_handler 56 | runtime = var.lambda_runtime 57 | architectures = var.lambda_architectures 58 | iam_role_arn = aws_iam_role.this.arn 59 | } 60 | 61 | // Oregon 62 | module "region-us-west-2" { 63 | providers = { aws = aws.us-west-2 } 64 | source = "../modules/lambda" 65 | log_retention = var.log_retention 66 | name = var.name 67 | tags = var.tags 68 | filename = var.lambda_filename 69 | handler = var.lambda_handler 70 | runtime = var.lambda_runtime 71 | architectures = var.lambda_architectures 72 | iam_role_arn = aws_iam_role.this.arn 73 | } 74 | 75 | // Dublin 76 | module "region-eu-west-1" { 77 | providers = { aws = aws.eu-west-1 } 78 | source = "../modules/lambda" 79 | log_retention = var.log_retention 80 | name = var.name 81 | tags = var.tags 82 | filename = var.lambda_filename 83 | handler = var.lambda_handler 84 | runtime = var.lambda_runtime 85 | architectures = var.lambda_architectures 86 | iam_role_arn = aws_iam_role.this.arn 87 | } 88 | 89 | // London 90 | module "region-eu-west-2" { 91 | providers = { aws = aws.eu-west-2 } 92 | source = "../modules/lambda" 93 | log_retention = var.log_retention 94 | name = var.name 95 | tags = var.tags 96 | filename = var.lambda_filename 97 | handler = var.lambda_handler 98 | runtime = var.lambda_runtime 99 | architectures = var.lambda_architectures 100 | iam_role_arn = aws_iam_role.this.arn 101 | } 102 | 103 | // Ohio 104 | module "region-us-east-2" { 105 | providers = { aws = aws.us-east-2 } 106 | source = "../modules/lambda" 107 | log_retention = var.log_retention 108 | name = var.name 109 | tags = var.tags 110 | filename = var.lambda_filename 111 | handler = var.lambda_handler 112 | runtime = var.lambda_runtime 113 | architectures = var.lambda_architectures 114 | iam_role_arn = aws_iam_role.this.arn 115 | } 116 | 117 | // Sao Paulo 118 | module "region-sa-east-1" { 119 | providers = { aws = aws.sa-east-1 } 120 | source = "../modules/lambda" 121 | log_retention = var.log_retention 122 | name = var.name 123 | tags = var.tags 124 | filename = var.lambda_filename 125 | handler = var.lambda_handler 126 | runtime = var.lambda_runtime 127 | architectures = var.lambda_architectures 128 | iam_role_arn = aws_iam_role.this.arn 129 | } 130 | 131 | // Frankfurt 132 | module "region-eu-central-1" { 133 | providers = { aws = aws.eu-central-1 } 134 | source = "../modules/lambda" 135 | log_retention = var.log_retention 136 | name = var.name 137 | tags = var.tags 138 | filename = var.lambda_filename 139 | handler = var.lambda_handler 140 | runtime = var.lambda_runtime 141 | architectures = var.lambda_architectures 142 | iam_role_arn = aws_iam_role.this.arn 143 | } 144 | 145 | // Paris 146 | module "region-eu-west-3" { 147 | providers = { aws = aws.eu-west-3 } 148 | source = "../modules/lambda" 149 | log_retention = var.log_retention 150 | name = var.name 151 | tags = var.tags 152 | filename = var.lambda_filename 153 | handler = var.lambda_handler 154 | runtime = var.lambda_runtime 155 | architectures = var.lambda_architectures 156 | iam_role_arn = aws_iam_role.this.arn 157 | } 158 | 159 | // Stockholm 160 | module "region-eu-north-1" { 161 | providers = { aws = aws.eu-north-1 } 162 | source = "../modules/lambda" 163 | log_retention = var.log_retention 164 | name = var.name 165 | tags = var.tags 166 | filename = var.lambda_filename 167 | handler = var.lambda_handler 168 | runtime = var.lambda_runtime 169 | architectures = var.lambda_architectures 170 | iam_role_arn = aws_iam_role.this.arn 171 | } 172 | 173 | // Canada 174 | module "region-ca-central-1" { 175 | providers = { aws = aws.ca-central-1 } 176 | source = "../modules/lambda" 177 | log_retention = var.log_retention 178 | name = var.name 179 | tags = var.tags 180 | filename = var.lambda_filename 181 | handler = var.lambda_handler 182 | runtime = var.lambda_runtime 183 | architectures = var.lambda_architectures 184 | iam_role_arn = aws_iam_role.this.arn 185 | } 186 | 187 | // Seoul 188 | module "region-ap-northeast-2" { 189 | providers = { aws = aws.ap-northeast-2 } 190 | source = "../modules/lambda" 191 | log_retention = var.log_retention 192 | name = var.name 193 | tags = var.tags 194 | filename = var.lambda_filename 195 | handler = var.lambda_handler 196 | runtime = var.lambda_runtime 197 | architectures = var.lambda_architectures 198 | iam_role_arn = aws_iam_role.this.arn 199 | } 200 | 201 | // Mumbai 202 | module "region-ap-south-1" { 203 | providers = { aws = aws.ap-south-1 } 204 | source = "../modules/lambda" 205 | log_retention = var.log_retention 206 | name = var.name 207 | tags = var.tags 208 | filename = var.lambda_filename 209 | handler = var.lambda_handler 210 | runtime = var.lambda_runtime 211 | architectures = var.lambda_architectures 212 | iam_role_arn = aws_iam_role.this.arn 213 | } 214 | 215 | // Singapore 216 | module "region-ap-southeast-1" { 217 | providers = { aws = aws.ap-southeast-1 } 218 | source = "../modules/lambda" 219 | log_retention = var.log_retention 220 | name = var.name 221 | tags = var.tags 222 | filename = var.lambda_filename 223 | handler = var.lambda_handler 224 | runtime = var.lambda_runtime 225 | architectures = var.lambda_architectures 226 | iam_role_arn = aws_iam_role.this.arn 227 | } 228 | 229 | // Sydney 230 | module "region-ap-southeast-2" { 231 | providers = { aws = aws.ap-southeast-2 } 232 | source = "../modules/lambda" 233 | log_retention = var.log_retention 234 | name = var.name 235 | tags = var.tags 236 | filename = var.lambda_filename 237 | handler = var.lambda_handler 238 | runtime = var.lambda_runtime 239 | architectures = var.lambda_architectures 240 | iam_role_arn = aws_iam_role.this.arn 241 | } 242 | 243 | // Tokyo 244 | module "region-ap-northeast-1" { 245 | providers = { aws = aws.ap-northeast-1 } 246 | source = "../modules/lambda" 247 | log_retention = var.log_retention 248 | name = var.name 249 | tags = var.tags 250 | filename = var.lambda_filename 251 | handler = var.lambda_handler 252 | runtime = var.lambda_runtime 253 | architectures = var.lambda_architectures 254 | iam_role_arn = aws_iam_role.this.arn 255 | } 256 | 257 | // Osaka 258 | module "region-ap-northeast-3" { 259 | providers = { aws = aws.ap-northeast-3 } 260 | source = "../modules/lambda" 261 | log_retention = var.log_retention 262 | name = var.name 263 | tags = var.tags 264 | filename = var.lambda_filename 265 | handler = var.lambda_handler 266 | runtime = var.lambda_runtime 267 | architectures = var.lambda_architectures 268 | iam_role_arn = aws_iam_role.this.arn 269 | } 270 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------