├── images ├── aws │ └── api_gateway │ │ ├── map.png │ │ ├── overall_diagram.png │ │ ├── rotating_process.png │ │ └── available_ips_per_region.png ├── azure │ └── cloud_shell │ │ ├── map.png │ │ ├── overall_diagram.png │ │ └── available_ips_per_region.png ├── github │ └── github_actions │ │ ├── map.png │ │ ├── overall_diagram.png │ │ └── available_ips_per_region.png └── ipspinner │ └── overall_diagram.png ├── Makefile ├── utils ├── rand.go ├── interfaces.go ├── maps.go ├── constants.go ├── slices.go ├── url.go ├── logger.go ├── strings.go ├── structs.go ├── network.go ├── files.go ├── requests.go └── crypto.go ├── providers ├── aws │ ├── auth.go │ ├── aws.go │ └── fireprox.go ├── github │ ├── github.go │ ├── sdk.go │ ├── repository.go │ └── files.go ├── azure │ ├── files.go │ ├── azure.go │ ├── account.go │ └── cloudshell.go └── providers.go ├── Containerfile ├── config.ini ├── go.mod ├── main.go ├── proxy └── proxy.go ├── go.sum └── README.md /images/aws/api_gateway/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/aws/api_gateway/map.png -------------------------------------------------------------------------------- /images/azure/cloud_shell/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/azure/cloud_shell/map.png -------------------------------------------------------------------------------- /images/github/github_actions/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/github/github_actions/map.png -------------------------------------------------------------------------------- /images/ipspinner/overall_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/ipspinner/overall_diagram.png -------------------------------------------------------------------------------- /images/aws/api_gateway/overall_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/aws/api_gateway/overall_diagram.png -------------------------------------------------------------------------------- /images/aws/api_gateway/rotating_process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/aws/api_gateway/rotating_process.png -------------------------------------------------------------------------------- /images/azure/cloud_shell/overall_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/azure/cloud_shell/overall_diagram.png -------------------------------------------------------------------------------- /images/github/github_actions/overall_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/github/github_actions/overall_diagram.png -------------------------------------------------------------------------------- /images/aws/api_gateway/available_ips_per_region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/aws/api_gateway/available_ips_per_region.png -------------------------------------------------------------------------------- /images/azure/cloud_shell/available_ips_per_region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/azure/cloud_shell/available_ips_per_region.png -------------------------------------------------------------------------------- /images/github/github_actions/available_ips_per_region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/IPSpinner/HEAD/images/github/github_actions/available_ips_per_region.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | pre-build: 4 | go mod tidy 5 | 6 | 7 | build-all: build-linux build-windows build-collector-linux build-collector-windows 8 | 9 | 10 | build: build-linux build-windows 11 | 12 | build-linux: pre-build 13 | GOOS=linux GOARCH=amd64 go build -o ipspinner . 14 | 15 | build-windows: pre-build 16 | GOOS=windows GOARCH=amd64 go build -o ipspinner.exe . 17 | 18 | 19 | clean: 20 | rm ipspinner 21 | rm ipspinner.exe 22 | -------------------------------------------------------------------------------- /utils/rand.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | ) 7 | 8 | // GenerateSecureRandomInt génère un nombre entier aléatoire entre 0 et max (exclusif) en utilisant crypto/rand. 9 | func generateSecureRandomInt(max int) int { 10 | if max <= 0 { 11 | return 0 12 | } 13 | 14 | // Convertir max en un type big.Int pour l'utiliser avec crypto/rand 15 | n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) 16 | if err != nil { 17 | return 0 18 | } 19 | 20 | // Convertir en int et retourner le résultat 21 | return int(n.Int64()) 22 | } 23 | -------------------------------------------------------------------------------- /utils/interfaces.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | ) 7 | 8 | type Provider interface { 9 | GetName() string 10 | SummarizeState() string 11 | GetAvailableLaunchers() []Launcher 12 | GetLaunchers() []Launcher 13 | GetNbTotalReqSent() int 14 | IsStopped() bool 15 | Clear() bool 16 | } 17 | 18 | type Launcher interface { 19 | GetName() string 20 | GetProvider() Provider 21 | GetNbTotalReqSent() int 22 | SummarizeState() string 23 | PreloadHosts(context.Context, []*url.URL) 24 | SendRequest(context.Context, HTTPRequestData, *AllConfigs) (HTTPResponseData, string, error) 25 | IsAvailable() bool 26 | IsStopped() bool 27 | Clear() bool 28 | } 29 | -------------------------------------------------------------------------------- /utils/maps.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // Returns a random key in the given map 4 | func RandomKeyOfMap[T any](m map[string]T) string { 5 | keys := make([]string, 0, len(m)) 6 | 7 | for key := range m { 8 | keys = append(keys, key) 9 | } 10 | 11 | if len(keys) == 0 { 12 | return "" 13 | } 14 | 15 | randomIndex := generateSecureRandomInt(len(keys)) 16 | 17 | return keys[randomIndex] 18 | } 19 | 20 | // Returns the value associated to the key in the given map if it exists, or the defaultValue otherwise 21 | func GetOrDefault[K comparable, V any](m map[K]V, key K, defaultValue V) V { 22 | if val, ok := m[key]; ok { 23 | return val 24 | } 25 | 26 | return defaultValue 27 | } 28 | -------------------------------------------------------------------------------- /providers/aws/auth.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "ipspinner/utils" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/config" 9 | "github.com/aws/aws-sdk-go-v2/credentials" 10 | ) 11 | 12 | const awsMaxAttempts = 30 13 | 14 | // Returns an AWS config with provided credentials and information: 15 | func GetConfig(ctx context.Context, accessKey, secretKey, sessionToken, region string) (aws.Config, error) { 16 | cfg, err := config.LoadDefaultConfig(ctx, 17 | config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, sessionToken)), 18 | config.WithRegion(region), 19 | config.WithRetryMaxAttempts(awsMaxAttempts), 20 | config.WithHTTPClient(utils.GetHTTPClient(nil)), 21 | ) 22 | 23 | return cfg, err 24 | } 25 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM debian 2 | 3 | ARG USERNAME=ipspinner 4 | ARG USER_UID=1000 5 | ARG USER_GID=$USER_UID 6 | 7 | RUN apt update 8 | RUN apt install -y build-essential curl wget 9 | 10 | # Install go 11 | RUN wget https://go.dev/dl/go1.22.1.linux-amd64.tar.gz 12 | RUN tar -C /usr/local -xzf go1.22.1.linux-amd64.tar.gz 13 | RUN rm -f go1.22.1.linux-amd64.tar.gz 14 | ENV PATH="$PATH:/usr/local/go/bin" 15 | 16 | COPY . /go/workspace/ipspinner 17 | WORKDIR /go/workspace/ipspinner 18 | 19 | RUN printf "go 1.22.1\n\nuse ./ipspinner" > /go/workspace/go.work 20 | 21 | RUN groupadd --gid $USER_GID $USERNAME \ 22 | && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME 23 | 24 | RUN chown -R $USERNAME /go/workspace/ipspinner 25 | 26 | USER $USERNAME 27 | WORKDIR /go/workspace/ipspinner 28 | 29 | RUN go mod tidy 30 | RUN make build-linux 31 | 32 | ENTRYPOINT ["/go/workspace/ipspinner/ipspinner"] 33 | -------------------------------------------------------------------------------- /utils/constants.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | const ( 4 | LogFile = "ipspinner.log" 5 | SummarizeStateInterval = 300 6 | MaxResourcePerFireProxInstance = 300 7 | IPSpinnerResponseHeaderPrefix = "X-IPSpinner-" 8 | ) 9 | 10 | // RandomWords are used for generating random sentences (to be stealthier when a string input is required => like for naming something) 11 | // And also because its funnier to have a "real" sentence rather than fjeqrjagfdfdqs for a repository name :) 12 | // Why are you still reading this? 13 | // Thanks ChatGPT for generating a such nice list :) 14 | var RandomWords = []string{ 15 | "apple", "banana", "cat", "dog", "elephant", "fish", "gorilla", "hat", "icecream", 16 | "jacket", "kangaroo", "lemon", "monkey", "ninja", "orange", "penguin", "queen", 17 | "rabbit", "snake", "tiger", "umbrella", "vampire", "whale", "xylophone", "yak", "zebra", 18 | } 19 | -------------------------------------------------------------------------------- /utils/slices.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // Removes the given element in the slice (removes it each time it appears) 4 | func DeleteElementFromSlice[T comparable](slice []T, element T) []T { 5 | newSlice := []T{} 6 | 7 | for _, e := range slice { 8 | if e != element { 9 | newSlice = append(newSlice, e) 10 | } 11 | } 12 | 13 | return newSlice 14 | } 15 | 16 | // Returns a random element from the slice 17 | func RandomElementInSlice[T any](slice []T) T { 18 | randomIndex := generateSecureRandomInt(len(slice)) 19 | 20 | return slice[randomIndex] 21 | } 22 | 23 | // Takes as input a list and a maxLength and subdivises the input list into a list of sublists with a maximym length of maxLength 24 | func SubdiviseSlice[T any](slice []T, maxSubSliceLength int) [][]T { 25 | allSubSlices := [][]T{} 26 | currentSubSlice := []T{} 27 | 28 | for _, element := range slice { 29 | currentSubSlice = append(currentSubSlice, element) 30 | 31 | if len(currentSubSlice) >= maxSubSliceLength { 32 | allSubSlices = append(allSubSlices, currentSubSlice) 33 | 34 | currentSubSlice = []T{} 35 | } 36 | } 37 | 38 | if len(currentSubSlice) > 0 { 39 | allSubSlices = append(allSubSlices, currentSubSlice) 40 | } 41 | 42 | return allSubSlices 43 | } 44 | -------------------------------------------------------------------------------- /utils/url.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | // A base URL is the [SCHEME]://[DOMAIN]:[PORT] 10 | func GetBaseURL(urlObj *url.URL) string { 11 | scheme := strings.ToLower(urlObj.Scheme) 12 | host := strings.ToLower(urlObj.Host) 13 | 14 | if scheme == "" { 15 | scheme = "https" 16 | } 17 | 18 | baseURL := fmt.Sprintf("%s://%s", scheme, host) 19 | 20 | port := urlObj.Port() 21 | 22 | if port == "" { 23 | if scheme == "https" { 24 | baseURL += ":443" 25 | } else { 26 | baseURL += ":80" 27 | } 28 | } 29 | 30 | return baseURL 31 | } 32 | 33 | // A path corresponds to the part after the host part 34 | // Ex: the path for "https://google.com/search/test" is "/search/test" 35 | func GetPathFromURL(urlObj *url.URL) string { 36 | schemeLength := 0 37 | 38 | if len(urlObj.Scheme) > 0 { 39 | schemeLength = len(urlObj.Scheme) + 3 40 | } 41 | 42 | prefixLength := schemeLength + len(urlObj.Host) 43 | 44 | minIndex := min(len(urlObj.String()), prefixLength) 45 | 46 | return urlObj.String()[minIndex:] 47 | } 48 | 49 | // Generates a random string with lowercase letters 50 | func GenerateRandomPrefix(size int) string { 51 | const lowercaseLetters = "abcdefghijklmnopqrstuvwxyz" 52 | 53 | prefix := make([]byte, size) 54 | 55 | for i := 0; i < size; i++ { 56 | prefix[i] = lowercaseLetters[generateSecureRandomInt(len(lowercaseLetters))] 57 | } 58 | 59 | return string(prefix) 60 | } 61 | 62 | func CompareBaseURLs(url1, url2 *url.URL) bool { 63 | return GetBaseURL(url1) == GetBaseURL(url2) 64 | } 65 | 66 | func DoesURLListContainsBaseURL(urls []*url.URL, url1 *url.URL) bool { 67 | for _, url2 := range urls { 68 | if CompareBaseURLs(url1, url2) { 69 | return true 70 | } 71 | } 72 | 73 | return false 74 | } 75 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "time" 7 | 8 | "github.com/rs/zerolog" 9 | ) 10 | 11 | // https://stackoverflow.com/questions/76858037/how-to-use-zerolog-to-filter-info-logs-to-stdout-and-error-logs-to-stderr 12 | type SpecificLevelWriter struct { 13 | io.Writer 14 | Levels []zerolog.Level 15 | DebugVerbose *bool 16 | TraceVerbose *bool 17 | } 18 | 19 | var Logger zerolog.Logger 20 | var runLogFile *os.File 21 | 22 | // Closes the log file 23 | func CloseLogFile() error { 24 | return runLogFile.Close() 25 | } 26 | 27 | // Initialises the logger (specific rules and handlers) 28 | func InitializeLogger(verbose1, verbose2 bool) { 29 | runLogFile, _ := os.OpenFile( 30 | LogFile, 31 | os.O_APPEND|os.O_CREATE|os.O_WRONLY, 32 | fileMode, 33 | ) 34 | 35 | consolePartsToExclude := []string{zerolog.CallerFieldName} 36 | 37 | trueVar := true 38 | falseVar := false 39 | 40 | multi := zerolog.MultiLevelWriter( 41 | SpecificLevelWriter{ 42 | Writer: runLogFile, 43 | DebugVerbose: &trueVar, 44 | TraceVerbose: &falseVar, 45 | }, 46 | SpecificLevelWriter{ 47 | Writer: zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC822Z, PartsExclude: consolePartsToExclude}, 48 | DebugVerbose: &verbose1, 49 | TraceVerbose: &verbose2, 50 | }, 51 | ) 52 | 53 | Logger = zerolog.New(multi).With().Caller().Timestamp().Logger() 54 | } 55 | 56 | // Handler for the log level 57 | // Does log or not according to the verbose mode 58 | func (w SpecificLevelWriter) WriteLevel(level zerolog.Level, content []byte) (int, error) { 59 | if level == zerolog.TraceLevel && !*w.TraceVerbose { 60 | return len(content), nil 61 | } 62 | 63 | if level == zerolog.DebugLevel && !*w.DebugVerbose { 64 | return len(content), nil 65 | } 66 | 67 | return w.Write(content) 68 | } 69 | -------------------------------------------------------------------------------- /utils/strings.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // Generates a random password (at least one character of each class) (min size = 4) 11 | func GenerateRandomPassword(size int) string { 12 | const ( 13 | lowerChars = "abcdefghijklmnopqrstuvwxyz" 14 | upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 15 | numberChars = "0123456789" 16 | specialChars = "!.*" 17 | ) 18 | 19 | const allChars = lowerChars + upperChars + numberChars + specialChars 20 | 21 | password := randomChar(lowerChars) + randomChar(upperChars) + randomChar(numberChars) + randomChar(specialChars) 22 | 23 | for i := 0; i < (size - 4); i++ { //nolint:gomnd 24 | password += randomChar(allChars) 25 | } 26 | 27 | return shuffle(password) 28 | } 29 | 30 | // GenerateRandomSentence generates a random English sentence with X words 31 | func GenerateRandomSentence(size int) string { 32 | var sentence string 33 | 34 | for i := 0; i < size; i++ { 35 | if i > 0 { 36 | sentence += " " 37 | } 38 | 39 | wordIndex := generateSecureRandomInt(len(RandomWords)) 40 | sentence += RandomWords[wordIndex] 41 | } 42 | 43 | // Capitalize the first letter of the sentence (it's more beautiful, isn't it?) 44 | sentence = string(sentence[0]-32) + sentence[1:] //nolint:gomnd 45 | 46 | return sentence 47 | } 48 | 49 | func GenerateUUIDv4() string { 50 | return uuid.New().String() 51 | } 52 | 53 | func randomChar(str string) string { 54 | return string(str[generateSecureRandomInt(len(str))]) 55 | } 56 | 57 | func shuffle(str string) string { 58 | inRune := []rune(str) 59 | 60 | rand.Shuffle(len(inRune), func(i, j int) { 61 | inRune[i], inRune[j] = inRune[j], inRune[i] 62 | }) 63 | 64 | return string(inRune) 65 | } 66 | 67 | func PrepareBearerHeader(token string) string { 68 | return fmt.Sprintf("Bearer %s", token) 69 | } 70 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [proxy] 2 | #preload_hosts_file = /tmp/preloadHosts.txt 3 | #whitelist_hosts_file = /tmp/whitelist.txt 4 | #blacklist_hosts_file = /tmp/blacklist.txt # the blacklist is not taken into account if the whitelist is specified 5 | #ca_cert_file = 6 | #ca_cert_key_file = 7 | #user_agents_file=agents.txt 8 | debug_response_headers = true 9 | #wait_for_launcher_available_timeout=60 10 | 11 | [aws] 12 | ## AWS ## 13 | regions = eu-west-1,eu-west-2 14 | #profile = default # if enabled, it overrides access_key, secret_key and session_token parameters 15 | access_key = TO_FILL_IF_NEEDED 16 | secret_key = TO_FILL_IF_NEEDED 17 | #session_token = 18 | 19 | ## AWS API GATEWAY ## 20 | ag_enabled = false 21 | #ag_max_instances = 5 22 | #ag_rotate_nb_requests = 5000 23 | #ag_forwarded_for_range = 35.180.0.0/16 # AWS eu-west-3 from https://ip-ranges.amazonaws.com/ip-ranges.json 24 | #ag_instance_title_prefix = "fpr" # random three letter prefix by default 25 | #ag_instance_deployment_description = "IPSpinner FireProx Prod" # random sentence of english words by default 26 | #ag_instance_deployment_stage_description = "IPSpinner FireProx Prod Stage" # random sentence of english words by default 27 | #ag_instance_deployment_stage_name = "fireprox" # random sentence of english words by default 28 | 29 | [github] 30 | ## GITHUB ## 31 | username = TO_FILL_IF_NEEDED 32 | token = TO_FILL_IF_NEEDED 33 | 34 | ## GITHUB ACTIONS ## 35 | ga_enabled = false 36 | 37 | [azure] 38 | ## AZURE ## 39 | admin_email = TO_FILL_IF_NEEDED 40 | admin_password = TO_FILL_IF_NEEDED 41 | tenant_id = TO_FILL_IF_NEEDED 42 | subscription_id = TO_FILL_IF_NEEDED 43 | #accounts_file = azure_accounts.txt # can contain multiple accounts, one info per line : email, password (overrides admin_email and admin_password) 44 | 45 | ## AZURE CLOUD SHELL ## 46 | cs_enabled = false 47 | cs_preferred_locations = westus,westeurope #westus,southcentralus,eastus,northeurope,westeurope,centralindia,southeastasia## TO AVOID, not stable: westcentralus,eastus2euap,centraluseuap 48 | cs_nb_instances = 5 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module ipspinner 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0 7 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 8 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 9 | github.com/aws/aws-sdk-go-v2 v1.26.1 10 | github.com/aws/aws-sdk-go-v2/config v1.27.10 11 | github.com/aws/aws-sdk-go-v2/credentials v1.17.10 12 | github.com/aws/aws-sdk-go-v2/service/apigateway v1.23.6 13 | github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 14 | github.com/google/uuid v1.6.0 15 | github.com/gorilla/websocket v1.5.1 16 | github.com/jefflinse/githubsecret v1.0.2 17 | github.com/rs/zerolog v1.32.0 18 | gopkg.in/ini.v1 v1.67.0 19 | ) 20 | 21 | require ( 22 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect 23 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect 24 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect 25 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect 26 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect 27 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/sso v1.20.4 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect 33 | github.com/aws/smithy-go v1.20.2 // indirect 34 | github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 35 | github.com/kylelemons/godebug v1.1.0 // indirect 36 | github.com/mattn/go-colorable v0.1.13 // indirect 37 | github.com/mattn/go-isatty v0.0.19 // indirect 38 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 39 | golang.org/x/crypto v0.26.0 // indirect 40 | golang.org/x/net v0.28.0 // indirect 41 | golang.org/x/sys v0.23.0 // indirect 42 | golang.org/x/text v0.17.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /utils/structs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "net/url" 6 | ) 7 | 8 | type HTTPRequestData struct { 9 | URL *url.URL 10 | Method string 11 | Headers map[string]any 12 | Body *bytes.Buffer 13 | FollowRedirections bool 14 | } 15 | 16 | type HTTPResponseData struct { 17 | StatusCode int 18 | Headers map[string]any 19 | Body []byte 20 | } 21 | 22 | type HTTPRequestJSONData struct { 23 | URL *url.URL 24 | Method string 25 | Headers map[string]any 26 | Body map[string]any 27 | FollowRedirections bool 28 | } 29 | 30 | type HTTPResponseJSONData struct { 31 | StatusCode int 32 | Headers map[string]any 33 | Body map[string]any 34 | } 35 | 36 | type ProvidersConfig struct { 37 | AWSRegions []string 38 | AWSProfile string 39 | AWSAccessKey string 40 | AWSSecretKey string 41 | AWSSessionToken string 42 | AWSAGEnabled bool 43 | AWSAGMaxInstances int 44 | AWSAGRotateNbRequests int 45 | AWSAGForwardedForRange string 46 | AWSAGInstanceTitlePrefix string 47 | AWSAGInstanceDeploymentDescription string 48 | AWSAGInstanceDeploymentStageDescription string 49 | AWSAGInstanceDeploymentStageName string 50 | GitHubGAEnabled bool 51 | GitHubUsername string 52 | GitHubToken string 53 | AzureAdminEmail string 54 | AzureAdminPassword string 55 | AzureTenantID string 56 | AzureSubscriptionID string 57 | AzureAccountsFile string 58 | AzureCSEnabled bool 59 | AzureCSPreferredLocations []string 60 | AzureCSNbInstances int 61 | } 62 | 63 | type ProxyConfig struct { 64 | PreloadHostsFile string 65 | WhitelistHostsFile string 66 | BlacklistHostsFile string 67 | CaCertFile string 68 | CaCertKeyFile string 69 | DebugResponseHeaders bool 70 | UserAgentsFile string 71 | WaitForLauncherAvailableTimeout int 72 | } 73 | 74 | type CommandParameters struct { 75 | Host string 76 | Port int 77 | Verbose1 bool 78 | Verbose2 bool 79 | Verbose3 bool 80 | ExportCaCert bool 81 | ConfigINIPath string 82 | } 83 | 84 | type AllConfigs struct { 85 | ProvidersConfig ProvidersConfig 86 | ProxyConfig ProxyConfig 87 | CommandParameters CommandParameters 88 | } 89 | -------------------------------------------------------------------------------- /utils/network.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "math/big" 6 | "math/rand" 7 | "net" 8 | "time" 9 | ) 10 | 11 | const ExpectedBitSize = 128 12 | const PaddingSize = 16 13 | 14 | // Returns a random IP from the CIDR 15 | func RandomIPFromCIDR(cidr string) (net.IP, error) { 16 | _, ipNet, err := net.ParseCIDR(cidr) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | if ipNet.IP.To4() != nil { 22 | return randomIPv4FromCIDR(cidr) 23 | } 24 | 25 | return randomIPv6FromCIDR(cidr) 26 | } 27 | 28 | // randomIPv6FromCIDR generates a random IPv6 address from the given CIDR. 29 | func randomIPv6FromCIDR(cidr string) (net.IP, error) { 30 | _, ipv6Net, err := net.ParseCIDR(cidr) 31 | if err != nil { 32 | return nil, errors.New("invalid CIDR") 33 | } 34 | 35 | // Get the network start IP 36 | startIP := ipv6Net.IP 37 | startBytes := startIP.To16() 38 | 39 | if startBytes == nil { 40 | return nil, errors.New("invalid start IP in CIDR") 41 | } 42 | 43 | startInt := new(big.Int).SetBytes(startBytes) 44 | 45 | // Calculate the network size 46 | maskSize, totalBits := ipv6Net.Mask.Size() 47 | if totalBits != ExpectedBitSize { 48 | return nil, errors.New("invalid IPv6 network mask") 49 | } 50 | 51 | networkSize := ExpectedBitSize - maskSize 52 | 53 | // Calculate the maximum IP in the range (end IP) 54 | maxAdd := new(big.Int).Lsh(big.NewInt(1), uint(networkSize)) 55 | endInt := new(big.Int).Add(startInt, maxAdd) 56 | endInt.Sub(endInt, big.NewInt(1)) 57 | 58 | // Generate a random number in the range [startInt, endInt] 59 | diff := new(big.Int).Sub(endInt, startInt) 60 | randInt := new(big.Int).Rand(rand.New(rand.NewSource(time.Now().UnixNano())), new(big.Int).Add(diff, big.NewInt(1))) //nolint:gosec 61 | randInt.Add(randInt, startInt) 62 | 63 | // Convert back to IP 64 | randBytes := randInt.Bytes() 65 | if len(randBytes) < PaddingSize { 66 | padded := make([]byte, PaddingSize) 67 | copy(padded[PaddingSize-len(randBytes):], randBytes) 68 | randBytes = padded 69 | } 70 | 71 | randomIP := net.IP(randBytes) 72 | 73 | return randomIP, nil 74 | } 75 | 76 | // Returns a random IPv4 from the CIDR 77 | func randomIPv4FromCIDR(cidr string) (net.IP, error) { 78 | _, ipNet, err := net.ParseCIDR(cidr) 79 | 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | // Convert network IP to 32-bit integer 85 | start := ipNet.IP.To4() 86 | startInt := int(start[0])<<24 | int(start[1])<<16 | int(start[2])<<8 | int(start[3]) //nolint:revive 87 | 88 | // Calculate the number of possible addresses in the CIDR 89 | ones, bits := ipNet.Mask.Size() 90 | numAddresses := 1 << uint(bits-ones) 91 | 92 | // Generate a random offset within the range of possible addresses 93 | offset := generateSecureRandomInt(numAddresses) 94 | 95 | // Calculate the final IP address by adding the offset to the starting IP 96 | resultInt := startInt + offset 97 | result := make(net.IP, 4) //nolint:gomnd 98 | result[0] = byte(resultInt >> 24) //nolint:gomnd 99 | result[1] = byte(resultInt >> 16) //nolint:gomnd 100 | result[2] = byte(resultInt >> 8) //nolint:gomnd 101 | result[3] = byte(resultInt) 102 | 103 | return result, nil 104 | } 105 | -------------------------------------------------------------------------------- /utils/files.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "os/user" 8 | "runtime" 9 | "strings" 10 | ) 11 | 12 | const fileMode = 0600 //nolint:gocritic 13 | 14 | // Reads the content of the given file 15 | func ReadFileContent(filePath string) ([]byte, error) { 16 | content, err := os.ReadFile(filePath) 17 | 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return content, nil 23 | } 24 | 25 | // Write the given content into the given file 26 | func WriteFileContent(filePath string, content []byte) error { 27 | err := os.WriteFile(filePath, content, fileMode) 28 | 29 | return err 30 | } 31 | 32 | // ReadFileLines reads the content of the given file and returns each line as a string slice 33 | func ReadFileLines(filePath string) ([]string, error) { 34 | content, err := ReadFileContent(filePath) 35 | 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | lines := strings.Split(string(content), "\n") 41 | 42 | // Remove empty lines if any 43 | var cleanLines []string 44 | 45 | for _, line := range lines { 46 | if line != "" { 47 | cleanLines = append(cleanLines, line) 48 | } 49 | } 50 | 51 | return cleanLines, nil 52 | } 53 | 54 | // Parses a host file (a text file where each line contains a domain name or a URL) 55 | func ParseHostsFile(path string) []*url.URL { 56 | if path == "" { 57 | return []*url.URL{} 58 | } 59 | 60 | hostsStr, hostsStrErr := ReadFileLines(path) 61 | 62 | if hostsStrErr != nil { 63 | Logger.Warn().Err(hostsStrErr).Msg("Can not read preload hosts file.") 64 | 65 | return []*url.URL{} 66 | } 67 | 68 | hostLst := []*url.URL{} 69 | 70 | for _, hostStr := range hostsStr { 71 | // If this is only a domain name => it loads http and https endpoints 72 | if !strings.HasPrefix(hostStr, "http://") && !strings.HasPrefix(hostStr, "https://") { 73 | for _, prefix := range []string{"http://", "https://"} { 74 | newHostStr := prefix + hostStr 75 | host, hostErr := url.Parse(newHostStr) 76 | 77 | if hostErr != nil { 78 | Logger.Warn().Err(hostsStrErr).Str("host", newHostStr).Msg("Host can not be parsed as a valid URL.") 79 | 80 | continue 81 | } 82 | 83 | hostLst = append(hostLst, host) 84 | } 85 | } else { 86 | host, hostErr := url.Parse(hostStr) 87 | 88 | if hostErr != nil { 89 | Logger.Warn().Err(hostsStrErr).Str("host", hostStr).Msg("Host can not be parsed as a valid URL.") 90 | 91 | continue 92 | } 93 | 94 | hostLst = append(hostLst, host) 95 | } 96 | } 97 | 98 | return hostLst 99 | } 100 | 101 | func GetHomeDirectory() (string, error) { 102 | // Essayez d'abord d'utiliser os/user pour obtenir le répertoire home 103 | usr, err := user.Current() 104 | if err == nil { 105 | return usr.HomeDir, nil 106 | } 107 | 108 | // Si os/user échoue, utilisez les variables d'environnement 109 | if runtime.GOOS == "windows" { 110 | home := os.Getenv("USERPROFILE") 111 | 112 | if home != "" { 113 | return home, nil 114 | } 115 | } else { 116 | home := os.Getenv("HOME") 117 | 118 | if home != "" { 119 | return home, nil 120 | } 121 | } 122 | 123 | return "", fmt.Errorf("cannot retrieve the user home directory") 124 | } 125 | 126 | func FileExists(path string) bool { 127 | _, err := os.Stat(path) 128 | 129 | return !os.IsNotExist(err) 130 | } 131 | -------------------------------------------------------------------------------- /utils/requests.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | // Sends a HTTP request with the provided request json data and receives a json-body response 13 | func SendJSONRequest(reqJSONData HTTPRequestJSONData) (HTTPResponseJSONData, error) { 14 | body := &bytes.Buffer{} 15 | 16 | if reqJSONData.Body != nil { 17 | reqBodyErr := json.NewEncoder(body).Encode(reqJSONData.Body) 18 | 19 | if reqBodyErr != nil { 20 | return HTTPResponseJSONData{}, reqBodyErr 21 | } 22 | } else { 23 | body = nil 24 | } 25 | 26 | if _, ok := reqJSONData.Headers["Content-Type"]; !ok { 27 | reqJSONData.Headers["Content-Type"] = "application/json" 28 | } 29 | 30 | reqData := HTTPRequestData{ 31 | URL: reqJSONData.URL, 32 | Method: reqJSONData.Method, 33 | Headers: reqJSONData.Headers, 34 | Body: body, 35 | FollowRedirections: reqJSONData.FollowRedirections, 36 | } 37 | 38 | resp, respErr := SendRequest(reqData) 39 | 40 | if respErr != nil { 41 | return HTTPResponseJSONData{}, respErr 42 | } 43 | 44 | var bodyJSON map[string]any 45 | 46 | if len(resp.Body) > 0 { 47 | respBodyErr := json.Unmarshal(resp.Body, &bodyJSON) 48 | 49 | if respBodyErr != nil { 50 | return HTTPResponseJSONData{}, respBodyErr 51 | } 52 | } 53 | 54 | return HTTPResponseJSONData{ 55 | StatusCode: resp.StatusCode, 56 | Headers: resp.Headers, 57 | Body: bodyJSON, 58 | }, nil 59 | } 60 | 61 | // Sends a HTTP request with the provided request data 62 | func SendRequest(reqData HTTPRequestData) (HTTPResponseData, error) { 63 | body := reqData.Body 64 | 65 | if body == nil { 66 | body = &bytes.Buffer{} 67 | } 68 | 69 | req, reqErr := http.NewRequest(reqData.Method, reqData.URL.String(), body) 70 | 71 | if reqErr != nil { 72 | return HTTPResponseData{}, reqErr 73 | } 74 | 75 | // Sets the request headers 76 | for key, value := range reqData.Headers { 77 | req.Header.Set(key, fmt.Sprintf("%v", value)) 78 | } 79 | 80 | checkRedirect := func(req *http.Request, via []*http.Request) error { 81 | return http.ErrUseLastResponse 82 | } 83 | 84 | if reqData.FollowRedirections { 85 | checkRedirect = nil 86 | } 87 | 88 | // Sends the request 89 | client := GetHTTPClient(checkRedirect) 90 | 91 | resp, respErr := client.Do(req) 92 | 93 | if respErr != nil { 94 | return HTTPResponseData{}, respErr 95 | } 96 | 97 | defer resp.Body.Close() 98 | 99 | // Reads the response body 100 | respBody, respBodyErr := io.ReadAll(resp.Body) 101 | 102 | if respBodyErr != nil { 103 | return HTTPResponseData{}, respBodyErr 104 | } 105 | 106 | // Parses the response headers 107 | responseHeaders := make(map[string]any) 108 | 109 | for key, value := range resp.Header { 110 | if len(value) > 0 { 111 | responseHeaders[key] = value[0] 112 | } 113 | } 114 | 115 | return HTTPResponseData{ 116 | StatusCode: resp.StatusCode, 117 | Headers: responseHeaders, 118 | Body: respBody, 119 | }, nil 120 | } 121 | 122 | func GetHTTPClient(checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client { 123 | return &http.Client{ 124 | CheckRedirect: checkRedirect, 125 | Transport: &http.Transport{ 126 | Proxy: http.ProxyFromEnvironment, 127 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec 128 | }, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /providers/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "ipspinner/utils" 8 | ) 9 | 10 | type Infos struct { 11 | Accept string 12 | APIVersion string 13 | Token string 14 | Username string 15 | } 16 | 17 | // Provider object 18 | type GitHub struct { 19 | repositories []*Repository 20 | infos Infos 21 | stopped bool 22 | } 23 | 24 | //nolint:revive 25 | func (instance GitHub) GetName() string { 26 | return "GitHub" 27 | } 28 | 29 | func (instance GitHub) GetInfos() Infos { 30 | return instance.infos 31 | } 32 | 33 | func (instance GitHub) SummarizeState() string { 34 | if !instance.IsStopped() { 35 | return fmt.Sprintf("Provider %s is running with %d launcher(s).", instance.GetName(), len(instance.GetLaunchers())) 36 | } 37 | 38 | return fmt.Sprintf("Provider %s is stopped.", instance.GetName()) 39 | } 40 | 41 | func (instance *GitHub) GetAvailableLaunchers() []utils.Launcher { 42 | launchers := make([]utils.Launcher, 0) 43 | 44 | for _, launcher := range instance.GetLaunchers() { 45 | if launcher.IsAvailable() { 46 | launchers = append(launchers, launcher) 47 | } 48 | } 49 | 50 | return launchers 51 | } 52 | 53 | func (instance *GitHub) GetLaunchers() []utils.Launcher { 54 | launchers := make([]utils.Launcher, 0) 55 | 56 | for _, repository := range instance.repositories { 57 | launchers = append(launchers, repository) 58 | } 59 | 60 | return launchers 61 | } 62 | 63 | func (instance GitHub) GetNbTotalReqSent() int { 64 | count := 0 65 | 66 | for _, launcher := range instance.GetLaunchers() { 67 | count += launcher.GetNbTotalReqSent() 68 | } 69 | 70 | return count 71 | } 72 | 73 | func (instance GitHub) IsStopped() bool { 74 | return instance.stopped 75 | } 76 | 77 | func (instance *GitHub) Clear() bool { 78 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("Clearing provider.") 79 | 80 | instance.stopped = true 81 | 82 | fullyCleared := true 83 | 84 | for _, launcher := range instance.GetLaunchers() { 85 | cleared := launcher.Clear() 86 | 87 | if !cleared { 88 | fullyCleared = false 89 | } 90 | } 91 | 92 | return fullyCleared 93 | } 94 | 95 | // Creates and initializes a new instance of the GitHub provider object 96 | // revive:disable:unused-parameter 97 | func Initialize(ctx context.Context, allConfigs *utils.AllConfigs) (GitHub, error) { 98 | instance := GitHub{stopped: false} 99 | 100 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("Configuring provider.") 101 | 102 | instance.infos = Infos{ 103 | Accept: "application/vnd.github+json", 104 | APIVersion: "2022-11-28", 105 | Token: allConfigs.ProvidersConfig.GitHubToken, 106 | Username: allConfigs.ProvidersConfig.GitHubUsername, 107 | } 108 | 109 | if allConfigs.ProvidersConfig.GitHubGAEnabled { 110 | instance.loadRepositoryLaunchers() 111 | } 112 | 113 | if len(instance.GetLaunchers()) == 0 { 114 | return instance, errors.New("no launchers could have been created") 115 | } 116 | 117 | return instance, nil 118 | } 119 | 120 | // revive:enable:unused-parameter 121 | 122 | func (instance *GitHub) loadRepositoryLaunchers() { 123 | launcher, launcherErr := CreateRepositoryLauncher(instance) 124 | 125 | if launcherErr != nil { 126 | utils.Logger.Error().Err(launcherErr).Msg("Cannot create repository workers launcher.") 127 | } else { 128 | instance.repositories = append(instance.repositories, launcher) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /providers/azure/files.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | const SCRIPT_PY_FILE_BASE64 string = "aW1wb3J0IHJlcXVlc3RzCmZyb20gdXJsbGliMy5leGNlcHRpb25zIGltcG9ydCBJbnNlY3VyZVJlcXVlc3RXYXJuaW5nCmltcG9ydCBiYXNlNjQKaW1wb3J0IHN5cwppbXBvcnQgdHlwaW5nCgpyZXF1ZXN0cy5wYWNrYWdlcy51cmxsaWIzLmRpc2FibGVfd2FybmluZ3MoKSAKCmlmIGxlbihzeXMuYXJndikgPCAzOgogICAgcmFpc2UgRXhjZXB0aW9uKCJVc2FnZTogcHl0aG9uMyBzY3JpcHRzLnB5IE1FVEhPRF9FTkMgVVJMX0VOQyBbSEVBREVSU19FTkNdIFtCT0RZX0VOQ10iKQoKQ0hVTktfU0laRSA9IDUwMDAKCm1ldGhvZEVuYyA9IHN5cy5hcmd2WzFdCnVybEVuYyA9IHN5cy5hcmd2WzJdCmhlYWRlcnNFbmMgPSBzeXMuYXJndlszXSBpZiBsZW4oc3lzLmFyZ3YpID49IDQgZWxzZSAiIgpib2R5RW5jID0gc3lzLmFyZ3ZbNF0gaWYgbGVuKHN5cy5hcmd2KSA+PSA1IGVsc2UgIiIKCmRlZiBwYXJzZUlucHV0cyhtZXRob2RFbmM6IHN0ciwgdXJsRW5jOiBzdHIsIGhlYWRlcnNFbmM6IHN0ciwgYm9keUVuYzogc3RyKSAtPiB0eXBpbmcuVHVwbGVbYnl0ZXMsIGJ5dGVzLCBkaWN0LCBieXRlc106CiAgICBtZXRob2QgPSBiYXNlNjQuYjY0ZGVjb2RlKG1ldGhvZEVuYykKICAgIHVybCA9IGJhc2U2NC5iNjRkZWNvZGUodXJsRW5jKQogICAgCiAgICBoZWFkZXJzID0ge30KICAgIGlmIGxlbihoZWFkZXJzRW5jKSA+IDA6CiAgICAgICAgaGVhZGVyc1JhdyA9IGJhc2U2NC5iNjRkZWNvZGUoaGVhZGVyc0VuYykuZGVjb2RlKCkKICAgICAgICBoZWFkZXJzTGluZXMgPSBoZWFkZXJzUmF3LnNwbGl0bGluZXMoKQogICAgICAgIGlmIGxlbihoZWFkZXJzTGluZXMpJTIgIT0gMDoKICAgICAgICAgICAgcmFpc2UgRXhjZXB0aW9uKCJDYW4gbm90IHBhcnNlIHRoZSByZXF1ZXN0IGhlYWRlcnMuIikKCiAgICAgICAgZm9yIGkgaW4gcmFuZ2UoMCwgbGVuKGhlYWRlcnNMaW5lcyksIDIpOgogICAgICAgICAgICBoZWFkZXJzW2hlYWRlcnNMaW5lc1tpXV0gPSBoZWFkZXJzTGluZXNbaSsxXQoKICAgIGJvZHkgPSBOb25lCiAgICBpZiBsZW4oYm9keUVuYykgPiAwOgogICAgICAgIGJvZHkgPSBiYXNlNjQuYjY0ZGVjb2RlKGJvZHlFbmMpCgogICAgcmV0dXJuIG1ldGhvZCwgdXJsLCBoZWFkZXJzLCBib2R5CgpkZWYgZW5jb2RlT3V0cHV0cyhzdGF0dXM6IGludCwgaGVhZGVyczogZGljdCwgYm9keTogYnl0ZXMpIC0+IHR5cGluZy5UdXBsZVtzdHIsIHN0ciwgc3RyXToKICAgIHN0YXR1c0VuYyA9ICIiCiAgICBpZiBzdGF0dXMgIT0gTm9uZToKICAgICAgICBzdGF0dXNFbmMgPSBiYXNlNjQuYjY0ZW5jb2RlKHN0cihzdGF0dXMpLmVuY29kZSgpKS5kZWNvZGUoKQoKICAgIGhlYWRlcnNTdHIgPSAiIgogICAgaWYgaGVhZGVycyAhPSBOb25lOgogICAgICAgIGZvciBrZXkgaW4gaGVhZGVyczoKICAgICAgICAgICAgaGVhZGVyc1N0ciArPSBrZXkgKyAiXG4iICsgaGVhZGVyc1trZXldICsgIlxuIgogICAgICAgIGlmIGxlbihoZWFkZXJzU3RyKSA+IDA6CiAgICAgICAgICAgIGhlYWRlcnNTdHIgPSBoZWFkZXJzU3RyWzotMV0KICAgIGhlYWRlcnNFbmMgPSBiYXNlNjQuYjY0ZW5jb2RlKGhlYWRlcnNTdHIuZW5jb2RlKCkpLmRlY29kZSgpCgogICAgYm9keUVuYyA9ICIiCiAgICBpZiBib2R5ICE9IE5vbmU6CiAgICAgICAgYm9keUVuYyA9IGJhc2U2NC5iNjRlbmNvZGUoYm9keSkuZGVjb2RlKCkKCiAgICByZXR1cm4gc3RhdHVzRW5jLCBoZWFkZXJzRW5jLCBib2R5RW5jCgpkZWYgbWFrZVJlcXVlc3QobWV0aG9kOiBieXRlcywgdXJsOiBieXRlcywgaGVhZGVyczogZGljdD1Ob25lLCBib2R5OiBieXRlcz1Ob25lKToKICAgIHRyeToKICAgICAgICByZXNwb25zZSA9IHJlcXVlc3RzLnJlcXVlc3QobWV0aG9kLCB1cmwsIGhlYWRlcnM9aGVhZGVycywgZGF0YT1ib2R5LCB2ZXJpZnk9RmFsc2UsIGFsbG93X3JlZGlyZWN0cz1GYWxzZSwgc3RyZWFtPVRydWUpCiAgICAKICAgICAgICBzdGF0dXMgPSByZXNwb25zZS5zdGF0dXNfY29kZQogICAgICAgIGhlYWRlcnMgPSByZXNwb25zZS5oZWFkZXJzCiAgICAgICAgYm9keSA9IHJlc3BvbnNlLnJhdy5yZWFkKCkKCiAgICAgICAgcmV0dXJuIHN0YXR1cywgaGVhZGVycywgYm9keSwgTm9uZQogICAgZXhjZXB0IHJlcXVlc3RzLlJlcXVlc3RFeGNlcHRpb24gYXMgZToKICAgICAgICByZXR1cm4gTm9uZSwgTm9uZSwgTm9uZSwgc3RyKGUpCgoKcmVxTWV0aG9kLCByZXFVcmwsIHJlcUhlYWRlcnMsIHJlcUJvZHkgPSBwYXJzZUlucHV0cyhtZXRob2RFbmMsIHVybEVuYywgaGVhZGVyc0VuYywgYm9keUVuYykKCnJlc3BTdGF0dXMsIHJlc3BIZWFkZXJzLCByZXNwQm9keSwgcmVzcEVyciA9IG1ha2VSZXF1ZXN0KHJlcU1ldGhvZCwgcmVxVXJsLCByZXFIZWFkZXJzLCByZXFCb2R5KQoKcHJpbnQoIlJFU1BfU1RBUlQiKQoKaWYgcmVzcEVyciAhPSBOb25lOgogICAgcHJpbnQoIlJFU1BfRVJSIiwgcmVzcEVycikKZWxzZToKICAgIHJlc3BTdGF0dXNFbmMsIHJlc3BIZWFkZXJzRW5jLCByZXNwQm9keUVuYyA9IGVuY29kZU91dHB1dHMocmVzcFN0YXR1cywgcmVzcEhlYWRlcnMsIHJlc3BCb2R5KQoKICAgIHByaW50KCJSRVNQX1NUQVRVU19FTkMiLCByZXNwU3RhdHVzRW5jKQogICAgcHJpbnQoIlJFU1BfSEVBREVSU19FTkMiLCByZXNwSGVhZGVyc0VuYykKICAgICMgV2UgY2h1bmsgdGhlIHJlc3BvbnNlIGJvZHkgaW4gdGhlIGNhc2UgdGhlIGJvZHkgaXMgdG9vIGxhcmdlCiAgICBmb3IgaSBpbiByYW5nZSgwLCBsZW4ocmVzcEJvZHlFbmMpLCBDSFVOS19TSVpFKToKICAgICAgICBwcmludCgiUkVTUF9CT0RZX0VOQyIsIHJlc3BCb2R5RW5jW2k6aSArIENIVU5LX1NJWkVdKQogICAgcHJpbnQoIlJFU1BfRU5EIik=" //nolint:revive,stylecheck 4 | -------------------------------------------------------------------------------- /utils/crypto.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "crypto/x509/pkix" 10 | "encoding/hex" 11 | "encoding/pem" 12 | "io" 13 | "math/big" 14 | "time" 15 | ) 16 | 17 | const rsaKeyBitsSize = 2048 18 | const aesKeyBytesSize = 32 19 | 20 | // Generates and returns a RSA CA certificate and key 21 | func GenerateRSACACertificate() (cert, key []byte, err error) { 22 | // Generates a RSA key 23 | privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBitsSize) 24 | if err != nil { 25 | return []byte{}, []byte{}, err 26 | } 27 | 28 | // Creates a self-signed certificate 29 | template := x509.Certificate{ 30 | SerialNumber: big.NewInt(1), 31 | Subject: pkix.Name{Organization: []string{"IPSpinner Tool"}}, 32 | NotBefore: time.Now(), 33 | NotAfter: time.Now().Add(365 * 24 * time.Hour), 34 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 35 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, 36 | BasicConstraintsValid: true, 37 | IsCA: true, 38 | } 39 | 40 | certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) 41 | if err != nil { 42 | return []byte{}, []byte{}, err 43 | } 44 | 45 | // Converts the certificate in a PEM format 46 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) 47 | 48 | // Converts the key in a PEM format 49 | privBytes := x509.MarshalPKCS1PrivateKey(privateKey) 50 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes}) 51 | 52 | return certPEM, keyPEM, nil 53 | } 54 | 55 | func Aes256GenerateKey() (string, error) { 56 | bytes := make([]byte, aesKeyBytesSize) // generate a random 32 byte key for AES-256 57 | 58 | if _, err := rand.Read(bytes); err != nil { 59 | return "", err 60 | } 61 | 62 | return hex.EncodeToString(bytes), nil 63 | } 64 | 65 | func Aes256Encrypt(dataStr, keyHexStr string) (string, error) { 66 | key, _ := hex.DecodeString(keyHexStr) 67 | 68 | plaintext := []byte(dataStr) 69 | 70 | // Create a new Cipher Block from the key 71 | block, err := aes.NewCipher(key) 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | // Create a new GCM - https://en.wikipedia.org/wiki/Galois/Counter_Mode 77 | // https://golang.org/pkg/crypto/cipher/#NewGCM 78 | aesGCM, err := cipher.NewGCM(block) 79 | if err != nil { 80 | return "", err 81 | } 82 | 83 | // Create a nonce. Nonce should be from GCM 84 | nonce := make([]byte, aesGCM.NonceSize()) 85 | if _, err = io.ReadFull(rand.Reader, nonce); err != nil { 86 | return "", err 87 | } 88 | 89 | // Encrypt the data using aesGCM.Seal 90 | // Since we don't want to save the nonce somewhere else in this case, we add it as a prefix to the encrypted data. The first nonce argument in Seal is the prefix. 91 | ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil) 92 | 93 | return hex.EncodeToString(ciphertext), nil 94 | } 95 | 96 | func Aes256Decrypt(encryptedDataHexStr, keyHexStr string) (string, error) { 97 | key, _ := hex.DecodeString(keyHexStr) 98 | enc, _ := hex.DecodeString(encryptedDataHexStr) 99 | 100 | // Create a new Cipher Block from the key 101 | block, err := aes.NewCipher(key) 102 | if err != nil { 103 | return "", err 104 | } 105 | 106 | // Create a new GCM 107 | aesGCM, err := cipher.NewGCM(block) 108 | if err != nil { 109 | return "", err 110 | } 111 | 112 | // Get the nonce size 113 | nonceSize := aesGCM.NonceSize() 114 | 115 | // Extract the nonce from the encrypted data 116 | nonce, ciphertext := enc[:nonceSize], enc[nonceSize:] 117 | 118 | // Decrypt the data 119 | plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) 120 | if err != nil { 121 | return "", err 122 | } 123 | 124 | return string(plaintext), nil 125 | } 126 | -------------------------------------------------------------------------------- /providers/providers.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "ipspinner/providers/aws" 6 | "ipspinner/providers/azure" 7 | "ipspinner/providers/github" 8 | "ipspinner/utils" 9 | "net/url" 10 | "strings" 11 | ) 12 | 13 | // Initializes and returns all available providers 14 | func LoadProviders(ctx context.Context, allConfigs *utils.AllConfigs) []utils.Provider { 15 | utils.Logger.Info().Msg("Loading providers, please do not press ctrl+c.") 16 | 17 | providers := []utils.Provider{} 18 | 19 | // For AWS FireProx 20 | if allConfigs.ProvidersConfig.AWSAGEnabled { 21 | provider, providerErr := aws.Initialize(ctx, allConfigs) 22 | 23 | if providerErr != nil { 24 | utils.Logger.Error().Err(providerErr).Str("provider", provider.GetName()).Msg("Can not load the provider.") 25 | 26 | provider.Clear() 27 | } else { 28 | providers = append(providers, provider) 29 | } 30 | } 31 | 32 | // For Github Actions 33 | if allConfigs.ProvidersConfig.GitHubGAEnabled { 34 | if strings.EqualFold(allConfigs.ProvidersConfig.GitHubUsername, "synacktiv") { 35 | utils.Logger.Error().Msg("Are you crazy ? Do not run IPSpinner on the Github Synacktiv account, we may be banned...") 36 | } else { 37 | provider, providerErr := github.Initialize(ctx, allConfigs) 38 | 39 | if providerErr != nil { 40 | utils.Logger.Error().Err(providerErr).Str("provider", provider.GetName()).Msg("Can not load the provider.") 41 | 42 | provider.Clear() 43 | } else { 44 | providers = append(providers, &provider) 45 | } 46 | } 47 | } 48 | 49 | // For Azure Cloudshell 50 | if allConfigs.ProvidersConfig.AzureCSEnabled { 51 | provider, providerErr := azure.Initialize(ctx, allConfigs) 52 | 53 | if providerErr != nil { 54 | utils.Logger.Error().Err(providerErr).Str("provider", provider.GetName()).Msg("Can not load the provider.") 55 | 56 | provider.Clear() 57 | } else { 58 | providers = append(providers, provider) 59 | } 60 | } 61 | 62 | preloadHosts := utils.ParseHostsFile(allConfigs.ProxyConfig.PreloadHostsFile) 63 | whitelistHosts := utils.ParseHostsFile(allConfigs.ProxyConfig.WhitelistHostsFile) 64 | blacklistHosts := utils.ParseHostsFile(allConfigs.ProxyConfig.BlacklistHostsFile) 65 | 66 | newPreloadHosts := []*url.URL{} 67 | 68 | // If a whitelist or a blacklist is provided, it checks if URLs in preload hosts list are suitable 69 | // The whitelist is prioritary on the blacklist 70 | if len(whitelistHosts) > 0 || len(blacklistHosts) > 0 { 71 | for _, preloadHost := range preloadHosts { 72 | if len(whitelistHosts) > 0 { // Whitelist is used 73 | if utils.DoesURLListContainsBaseURL(whitelistHosts, preloadHost) { 74 | newPreloadHosts = append(newPreloadHosts, preloadHost) 75 | } else { 76 | utils.Logger.Warn().Str("host", preloadHost.String()).Msg("The host has been removed from the preloading hosts because it is not mentioned in the whitelist.") 77 | } 78 | } else if len(blacklistHosts) > 0 { // if the whitelist is not used and the blacklist is used 79 | if utils.DoesURLListContainsBaseURL(blacklistHosts, preloadHost) { 80 | utils.Logger.Warn().Str("host", preloadHost.String()).Msg("The host has been removed from the preloading hosts because it is mentioned in the blacklist.") 81 | } else { 82 | newPreloadHosts = append(newPreloadHosts, preloadHost) 83 | } 84 | } 85 | } 86 | } else { 87 | newPreloadHosts = preloadHosts 88 | } 89 | 90 | if len(newPreloadHosts) > 0 { 91 | for _, provider := range providers { 92 | for _, launcher := range provider.GetLaunchers() { 93 | launcher.PreloadHosts(ctx, newPreloadHosts) 94 | } 95 | } 96 | } 97 | 98 | return providers 99 | } 100 | 101 | // Clears all configured providers 102 | func ClearProviders(providers []utils.Provider) { 103 | result := true 104 | 105 | for _, provider := range providers { 106 | result = result && provider.Clear() 107 | } 108 | 109 | if result { 110 | utils.Logger.Info().Msg("All providers have been cleared.") 111 | } else { 112 | utils.Logger.Error().Msg("Some providers have not been cleared.") 113 | } 114 | } 115 | 116 | func GetAllLaunchers(providers []utils.Provider) []*utils.Launcher { 117 | launchers := make([]*utils.Launcher, 0) 118 | 119 | for _, provider := range providers { 120 | for _, launcher := range provider.GetLaunchers() { 121 | launcherCopy := launcher 122 | 123 | launchers = append(launchers, &launcherCopy) 124 | } 125 | } 126 | 127 | return launchers 128 | } 129 | 130 | func GetAllAvailableLaunchers(providers []utils.Provider) []*utils.Launcher { 131 | launchers := make([]*utils.Launcher, 0) 132 | 133 | for _, provider := range providers { 134 | for _, launcher := range provider.GetAvailableLaunchers() { 135 | launcherCopy := launcher 136 | 137 | launchers = append(launchers, &launcherCopy) 138 | } 139 | } 140 | 141 | return launchers 142 | } 143 | -------------------------------------------------------------------------------- /providers/aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "ipspinner/utils" 8 | "path" 9 | 10 | awssdk "github.com/aws/aws-sdk-go-v2/aws" 11 | "gopkg.in/ini.v1" 12 | ) 13 | 14 | type AWS struct { 15 | awsConfigs map[string]*awssdk.Config 16 | fireProxInstances []*FireProx 17 | stopped bool 18 | } 19 | 20 | //nolint:revive 21 | func (instance AWS) GetName() string { 22 | return "AWS" 23 | } 24 | 25 | func (instance AWS) SummarizeState() string { 26 | if !instance.IsStopped() { 27 | return fmt.Sprintf("Provider %s is running with %d launcher(s).", instance.GetName(), len(instance.GetLaunchers())) 28 | } 29 | 30 | return fmt.Sprintf("Provider %s is stopped.", instance.GetName()) 31 | } 32 | 33 | func (instance *AWS) GetAvailableLaunchers() []utils.Launcher { 34 | launchers := make([]utils.Launcher, 0) 35 | 36 | for _, launcher := range instance.GetLaunchers() { 37 | if launcher.IsAvailable() { 38 | launchers = append(launchers, launcher) 39 | } 40 | } 41 | 42 | return launchers 43 | } 44 | 45 | func (instance *AWS) GetLaunchers() []utils.Launcher { 46 | launchers := make([]utils.Launcher, 0) 47 | 48 | for _, fireProx := range instance.fireProxInstances { 49 | launchers = append(launchers, fireProx) 50 | } 51 | 52 | return launchers 53 | } 54 | 55 | func (instance AWS) GetNbTotalReqSent() int { 56 | count := 0 57 | 58 | for _, launcher := range instance.GetLaunchers() { 59 | count += launcher.GetNbTotalReqSent() 60 | } 61 | 62 | return count 63 | } 64 | 65 | func (instance AWS) IsStopped() bool { 66 | return instance.stopped 67 | } 68 | 69 | func (instance *AWS) Clear() bool { 70 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("Clearing provider.") 71 | 72 | instance.stopped = true 73 | 74 | fullyCleared := true 75 | 76 | for _, launcher := range instance.GetLaunchers() { 77 | cleared := launcher.Clear() 78 | 79 | if !cleared { 80 | fullyCleared = false 81 | } 82 | } 83 | 84 | return fullyCleared 85 | } 86 | 87 | // Creates and initializes a new instance of the AWS provider object 88 | func Initialize(ctx context.Context, allConfigs *utils.AllConfigs) (*AWS, error) { 89 | instance := AWS{stopped: false} 90 | 91 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("Configuring provider.") 92 | 93 | awsConfigs, awsConfigsErr := loadAWSConfigs(ctx, allConfigs) 94 | 95 | if awsConfigsErr != nil { 96 | return &instance, awsConfigsErr 97 | } 98 | 99 | // If no awssdk.Config has been successfully instantiated => can not go further 100 | if len(awsConfigs) == 0 { 101 | return &instance, errors.New("no valid AWS configurations have been set up (please check the provided credentials and regions)") 102 | } 103 | 104 | instance.awsConfigs = awsConfigs 105 | 106 | if allConfigs.ProvidersConfig.AWSAGEnabled { 107 | instance.loadFireProxLaunchers(allConfigs) 108 | } 109 | 110 | if len(instance.GetLaunchers()) == 0 { 111 | return &instance, errors.New("no launchers could have been created") 112 | } 113 | 114 | return &instance, nil 115 | } 116 | 117 | func (instance *AWS) loadFireProxLaunchers(allConfigs *utils.AllConfigs) { 118 | maxFireProxInstances := allConfigs.ProvidersConfig.AWSAGMaxInstances 119 | fireProxTitlePrefix := allConfigs.ProvidersConfig.AWSAGInstanceTitlePrefix 120 | fireProxDeploymentDescription := allConfigs.ProvidersConfig.AWSAGInstanceDeploymentDescription 121 | fireProxDeploymentStageDescription := allConfigs.ProvidersConfig.AWSAGInstanceDeploymentStageDescription 122 | fireProxDeploymentStageName := allConfigs.ProvidersConfig.AWSAGInstanceDeploymentStageName 123 | rotateAPIGateway := allConfigs.ProvidersConfig.AWSAGRotateNbRequests 124 | 125 | fireProx, fireProxErr := CreateFireProx(instance, maxFireProxInstances, fireProxTitlePrefix, fireProxDeploymentDescription, fireProxDeploymentStageDescription, fireProxDeploymentStageName, rotateAPIGateway) 126 | 127 | if fireProxErr != nil { 128 | utils.Logger.Error().Err(fireProxErr).Msg("Cannot create FireProx launcher.") 129 | 130 | return 131 | } 132 | 133 | instance.fireProxInstances = append(instance.fireProxInstances, fireProx) 134 | } 135 | 136 | func loadAWSConfigs(ctx context.Context, allConfigs *utils.AllConfigs) (map[string]*awssdk.Config, error) { 137 | awsConfigs := make(map[string]*awssdk.Config, 0) 138 | 139 | accessKey := allConfigs.ProvidersConfig.AWSAccessKey 140 | secretKey := allConfigs.ProvidersConfig.AWSSecretKey 141 | sessionToken := allConfigs.ProvidersConfig.AWSSessionToken 142 | 143 | if len(allConfigs.ProvidersConfig.AWSProfile) > 0 { 144 | homeDirectory, homeDirectoryErr := utils.GetHomeDirectory() 145 | 146 | if homeDirectoryErr != nil { 147 | return awsConfigs, homeDirectoryErr 148 | } 149 | 150 | awsCredentialsFilePath := path.Join(homeDirectory, ".aws", "credentials") 151 | 152 | if !utils.FileExists(awsCredentialsFilePath) { 153 | return awsConfigs, fmt.Errorf("the aws credentials file does not exist or is not at its default location (%s)", awsCredentialsFilePath) 154 | } 155 | 156 | cfg, err := ini.Load(awsCredentialsFilePath) 157 | 158 | if err != nil { 159 | return awsConfigs, errors.New("cannot parse the aws credentials file") 160 | } 161 | 162 | if !cfg.HasSection(allConfigs.ProvidersConfig.AWSProfile) { 163 | return awsConfigs, fmt.Errorf("the user %s cannot be found in the aws credentials file", allConfigs.ProvidersConfig.AWSProfile) 164 | } 165 | 166 | accessKey = cfg.Section(allConfigs.ProvidersConfig.AWSProfile).Key("aws_access_key_id").String() 167 | secretKey = cfg.Section(allConfigs.ProvidersConfig.AWSProfile).Key("aws_secret_access_key").String() 168 | sessionToken = cfg.Section(allConfigs.ProvidersConfig.AWSProfile).Key("aws_session_token").String() 169 | } 170 | 171 | // Instantiates the awssdk.Config for each region 172 | for _, awsRegion := range allConfigs.ProvidersConfig.AWSRegions { 173 | utils.Logger.Info().Str("region", awsRegion).Msg("Creating AWS configuration.") 174 | 175 | awsConfig, err := GetConfig(ctx, accessKey, secretKey, sessionToken, awsRegion) 176 | 177 | if err != nil { 178 | utils.Logger.Warn().Err(err).Str("region", awsRegion).Msg("Can not authenticate to AWS with provided credentials on the region.") 179 | } else { 180 | awsConfigs[awsRegion] = &awsConfig 181 | } 182 | } 183 | 184 | return awsConfigs, nil 185 | } 186 | -------------------------------------------------------------------------------- /providers/azure/azure.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "ipspinner/utils" 8 | "time" 9 | 10 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" 11 | ) 12 | 13 | // Provider object 14 | type Azure struct { 15 | precreatedAccounts []*Account 16 | cloudShells []*CloudShell 17 | adminAccount *Account 18 | stopped bool 19 | } 20 | 21 | //nolint:revive 22 | func (instance Azure) GetName() string { 23 | return "Azure" 24 | } 25 | 26 | func (instance Azure) IsStopped() bool { 27 | return instance.stopped 28 | } 29 | 30 | func (instance Azure) SummarizeState() string { 31 | if !instance.IsStopped() { 32 | return fmt.Sprintf("Provider %s is running with %d launcher(s).", instance.GetName(), len(instance.GetLaunchers())) 33 | } 34 | 35 | return fmt.Sprintf("Provider %s is stopped.", instance.GetName()) 36 | } 37 | 38 | // Creates and initializes a new instance of the Azure provider object 39 | func Initialize(ctx context.Context, allConfigs *utils.AllConfigs) (*Azure, error) { //nolint:revive 40 | instance := Azure{stopped: false} 41 | 42 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("Configuring provider.") 43 | 44 | if allConfigs.ProvidersConfig.AzureAccountsFile == "" { 45 | adminAccount, adminAccountErr := ConnectAccount(allConfigs.ProvidersConfig.AzureAdminEmail, allConfigs.ProvidersConfig.AzureAdminPassword, allConfigs.ProvidersConfig.AzureTenantID, false, "", "") 46 | 47 | if adminAccountErr != nil { 48 | return &instance, adminAccountErr 49 | } 50 | 51 | instance.adminAccount = &adminAccount 52 | } else { 53 | loadAccountsErr := instance.loadAzurePrecreatedAccounts(allConfigs) 54 | 55 | if loadAccountsErr != nil { 56 | return &instance, loadAccountsErr 57 | } 58 | } 59 | 60 | if allConfigs.ProvidersConfig.AzureCSEnabled { 61 | instance.loadCloudShellLaunchers(allConfigs.ProvidersConfig.AzureCSNbInstances, allConfigs.ProvidersConfig.AzureSubscriptionID, allConfigs.ProvidersConfig.AzureCSPreferredLocations) 62 | } 63 | 64 | if len(instance.GetLaunchers()) == 0 { 65 | return &instance, errors.New("no launchers could have been created") 66 | } 67 | 68 | return &instance, nil 69 | } 70 | 71 | func (instance *Azure) loadAzurePrecreatedAccounts(allConfigs *utils.AllConfigs) error { 72 | utils.Logger.Debug().Str("provider", instance.GetName()).Msg("Loading precreated accounts.") 73 | 74 | tenantID := allConfigs.ProvidersConfig.AzureTenantID 75 | 76 | accountsFileLines, accountsFileLinesErr := utils.ReadFileLines(allConfigs.ProvidersConfig.AzureAccountsFile) 77 | 78 | if accountsFileLinesErr != nil { 79 | return accountsFileLinesErr 80 | } 81 | 82 | if len(accountsFileLines)%2 != 0 { 83 | return fmt.Errorf("the Azure accounts file does not respect the expected format: 2 lines per account (email, password)") 84 | } 85 | 86 | for i := 0; i < len(accountsFileLines)/2; i++ { 87 | email := accountsFileLines[i*2] 88 | password := accountsFileLines[i*2+1] 89 | 90 | account, accountErr := ConnectAccount(email, password, tenantID, false, "", "") 91 | 92 | if accountErr != nil { 93 | utils.Logger.Warn().Str("provider", instance.GetName()).Str("email", email).Msg("Cannot connect to this user.") 94 | 95 | continue 96 | } 97 | 98 | instance.precreatedAccounts = append(instance.precreatedAccounts, &account) 99 | } 100 | 101 | if len(instance.precreatedAccounts) == 0 { 102 | return fmt.Errorf("ipspinner was not able to connect to any of the precreated accounts") 103 | } 104 | 105 | return nil 106 | } 107 | 108 | func (instance *Azure) loadCloudShellLaunchers(nbLaunchers int, subscriptionID string, preferredLocations []string) { 109 | if len(preferredLocations) == 0 { 110 | preferredLocations = append(preferredLocations, "westeurope") 111 | } 112 | 113 | for i := 0; i < nbLaunchers; i++ { 114 | var account *Account 115 | 116 | if len(instance.precreatedAccounts) > 0 { 117 | if i >= len(instance.precreatedAccounts) { 118 | utils.Logger.Warn().Msg("No more precreated account available for creating a new CloudShell launcher.") 119 | 120 | continue 121 | } 122 | 123 | account = instance.precreatedAccounts[i] 124 | } else { 125 | accountCreated, accountCreatedErr := CreateCloudShellAccount(instance, subscriptionID) 126 | 127 | if accountCreatedErr != nil { 128 | utils.Logger.Error().Err(accountCreatedErr).Msg("Cannot create an account for this CloudShell launcher.") 129 | 130 | continue 131 | } 132 | 133 | account = &accountCreated 134 | } 135 | 136 | cloudShell, cloudShellErr := CreateCloudShell(instance, subscriptionID, preferredLocations[i%len(preferredLocations)], *account) 137 | 138 | if cloudShellErr != nil { 139 | utils.Logger.Error().Err(cloudShellErr).Msg("Cannot create cloud shell launcher.") 140 | 141 | // Waits for the account to be propagated 142 | time.Sleep(2 * time.Second) 143 | 144 | cloudShell.Clear() 145 | 146 | continue 147 | } 148 | 149 | instance.cloudShells = append(instance.cloudShells, cloudShell) 150 | } 151 | } 152 | 153 | func (instance *Azure) GetAvailableLaunchers() []utils.Launcher { 154 | launchers := make([]utils.Launcher, 0) 155 | 156 | for _, launcher := range instance.GetLaunchers() { 157 | if launcher.IsAvailable() { 158 | launchers = append(launchers, launcher) 159 | } 160 | } 161 | 162 | return launchers 163 | } 164 | 165 | func (instance *Azure) GetLaunchers() []utils.Launcher { 166 | launchers := make([]utils.Launcher, 0) 167 | 168 | for _, cloudShell := range instance.cloudShells { 169 | launchers = append(launchers, cloudShell) 170 | } 171 | 172 | return launchers 173 | } 174 | 175 | func (instance *Azure) GetAdminAccount() *Account { 176 | return instance.adminAccount 177 | } 178 | 179 | func (instance *Azure) Clear() bool { 180 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("Clearing provider.") 181 | 182 | instance.stopped = true 183 | 184 | fullyCleared := true 185 | 186 | for _, launcher := range instance.GetLaunchers() { 187 | cleared := launcher.Clear() 188 | 189 | if !cleared { 190 | fullyCleared = false 191 | } 192 | } 193 | 194 | return fullyCleared 195 | } 196 | 197 | func (instance Azure) GetNbTotalReqSent() int { 198 | count := 0 199 | 200 | for _, launcher := range instance.GetLaunchers() { 201 | count += launcher.GetNbTotalReqSent() 202 | } 203 | 204 | return count 205 | } 206 | 207 | //nolint:revive //tmp 208 | func (instance Azure) CreateResourceGroup() { 209 | rgClient, rgClientErr := armresources.NewResourceGroupsClient("f89fac99-3ac9-4650-942f-acd69f471c20", *instance.adminAccount.tokenCredential, nil) 210 | 211 | fmt.Println(rgClientErr) 212 | 213 | location := "francecentral" 214 | 215 | createResp, createErr := rgClient.CreateOrUpdate(context.Background(), "rgtest", armresources.ResourceGroup{ 216 | Location: &location, 217 | }, &armresources.ResourceGroupsClientCreateOrUpdateOptions{}) 218 | 219 | fmt.Printf("%+v \n", createResp) 220 | 221 | fmt.Println(createErr) 222 | } 223 | 224 | //nolint:revive //tmp 225 | func (instance Azure) DeleteResourceGroup() { 226 | rgClient, rgClientErr := armresources.NewResourceGroupsClient("f89fac99-3ac9-4650-942f-acd69f471c20", *instance.adminAccount.tokenCredential, nil) 227 | 228 | fmt.Println(rgClientErr) 229 | 230 | deleteResp, deleteErr := rgClient.BeginDelete(context.Background(), "rgtest", &armresources.ResourceGroupsClientBeginDeleteOptions{}) 231 | 232 | fmt.Printf("%+v \n", deleteResp) 233 | 234 | fmt.Println(deleteErr) 235 | } 236 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "ipspinner/providers" 9 | "ipspinner/proxy" 10 | "ipspinner/utils" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "syscall" 15 | "time" 16 | 17 | "gopkg.in/ini.v1" 18 | ) 19 | 20 | func main() { 21 | //////////////////////////// //nolint:revive 22 | // INITIALISATION SECTION // 23 | //////////////////////////// //nolint:revive 24 | commandParameters := parseFlags() 25 | 26 | utils.InitializeLogger(commandParameters.Verbose1, commandParameters.Verbose2) 27 | 28 | defer utils.CloseLogFile() //nolint:errcheck 29 | 30 | proxyConfig, providersConfig, parseConfigErr := parseConfig(commandParameters.ConfigINIPath) 31 | 32 | if parseConfigErr != nil { 33 | utils.Logger.Error().Err(parseConfigErr).Msg("Can not parse the configuration file.") 34 | 35 | return 36 | } 37 | 38 | allConfigs := utils.AllConfigs{ 39 | ProvidersConfig: providersConfig, 40 | ProxyConfig: proxyConfig, 41 | CommandParameters: commandParameters, 42 | } 43 | 44 | ////////////////// //nolint:revive 45 | // MAIN SECTION // 46 | ////////////////// //nolint:revive 47 | // Impede ctrl+c 48 | sigtermChan := make(chan os.Signal, 1) 49 | 50 | signal.Notify(sigtermChan, os.Interrupt, syscall.SIGTERM) 51 | 52 | go func() { 53 | <-sigtermChan 54 | }() 55 | 56 | providersLst := providers.LoadProviders(context.Background(), &allConfigs) 57 | 58 | if len(providers.GetAllLaunchers(providersLst)) == 0 { 59 | utils.Logger.Error().Msg("No launcher is available.") 60 | 61 | return 62 | } 63 | 64 | defer providers.ClearProviders(providersLst) 65 | 66 | summarizeStateRunning := launchSummarizeStateTask(&allConfigs, providersLst) 67 | 68 | srv, srvListenAddress, srvErr := launchProxy(context.Background(), &allConfigs, providersLst) 69 | 70 | if srvErr != nil { 71 | utils.Logger.Error().Err(srvErr).Str("listenAddress", srvListenAddress).Msg("Can not create and launch the proxy.") 72 | 73 | return 74 | } 75 | 76 | ////////////////// //nolint:revive 77 | // STOP SECTION // 78 | ////////////////// //nolint:revive 79 | // Waits for Ctrl+c 80 | sigChan := make(chan os.Signal, 1) 81 | 82 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 83 | 84 | <-sigChan 85 | 86 | *summarizeStateRunning = false 87 | 88 | utils.Logger.Info().Msg("Stopping proxy ...") 89 | 90 | srvCloseErr := srv.Close() 91 | 92 | if srvCloseErr != nil { 93 | utils.Logger.Error().Err(srvCloseErr).Msg("An error happened while stopping the proxy.") 94 | } 95 | } 96 | 97 | // Parses the configuration file 98 | // 99 | //nolint:revive 100 | func parseConfig(configFilePath string) (utils.ProxyConfig, utils.ProvidersConfig, error) { 101 | cfg, err := ini.Load(configFilePath) 102 | 103 | if err != nil { 104 | return utils.ProxyConfig{}, utils.ProvidersConfig{}, errors.New("can not retrieve or parse the configuration file") 105 | } 106 | 107 | return utils.ProxyConfig{ 108 | PreloadHostsFile: cfg.Section("proxy").Key("preload_hosts_file").String(), 109 | WhitelistHostsFile: cfg.Section("proxy").Key("whitelist_hosts_file").String(), 110 | BlacklistHostsFile: cfg.Section("proxy").Key("blacklist_hosts_file").String(), 111 | CaCertFile: cfg.Section("proxy").Key("ca_cert_file").String(), 112 | CaCertKeyFile: cfg.Section("proxy").Key("ca_cert_key_file").String(), 113 | UserAgentsFile: cfg.Section("proxy").Key("user_agents_file").String(), 114 | DebugResponseHeaders: cfg.Section("proxy").Key("debug_response_headers").MustBool(true), 115 | WaitForLauncherAvailableTimeout: cfg.Section("proxy").Key("wait_for_launcher_available_timeout").MustInt(60), //nolint:gomnd 116 | }, utils.ProvidersConfig{ 117 | AWSRegions: cfg.Section("aws").Key("regions").Strings(","), 118 | AWSProfile: cfg.Section("aws").Key("profile").String(), 119 | AWSAccessKey: cfg.Section("aws").Key("access_key").String(), 120 | AWSSecretKey: cfg.Section("aws").Key("secret_key").String(), 121 | AWSSessionToken: cfg.Section("aws").Key("session_token").String(), 122 | AWSAGEnabled: cfg.Section("aws").Key("ag_enabled").MustBool(false), 123 | AWSAGMaxInstances: cfg.Section("aws").Key("ag_max_instances").MustInt(5), //nolint:gomnd 124 | AWSAGRotateNbRequests: cfg.Section("aws").Key("ag_rotate_nb_requests").MustInt(5000), //nolint:gomnd 125 | AWSAGForwardedForRange: cfg.Section("aws").Key("ag_forwarded_for_range").MustString("35.180.0.0/16"), 126 | AWSAGInstanceTitlePrefix: cfg.Section("aws").Key("ag_instance_title_prefix").MustString(utils.GenerateRandomSentence(1)), 127 | AWSAGInstanceDeploymentDescription: cfg.Section("aws").Key("ag_instance_deployment_description").MustString(utils.GenerateRandomSentence(3)), 128 | AWSAGInstanceDeploymentStageDescription: cfg.Section("aws").Key("ag_instance_deployment_stage_description").MustString(utils.GenerateRandomSentence(3)), 129 | AWSAGInstanceDeploymentStageName: cfg.Section("aws").Key("ag_instance_deployment_stage_name").MustString(utils.GenerateRandomPrefix(10)), //nolint:gomnd 130 | GitHubUsername: cfg.Section("github").Key("username").String(), 131 | GitHubToken: cfg.Section("github").Key("token").String(), 132 | GitHubGAEnabled: cfg.Section("github").Key("ga_enabled").MustBool(false), 133 | AzureAdminEmail: cfg.Section("azure").Key("admin_email").String(), 134 | AzureAdminPassword: cfg.Section("azure").Key("admin_password").String(), 135 | AzureTenantID: cfg.Section("azure").Key("tenant_id").String(), 136 | AzureSubscriptionID: cfg.Section("azure").Key("subscription_id").String(), 137 | AzureAccountsFile: cfg.Section("azure").Key("accounts_file").String(), 138 | AzureCSEnabled: cfg.Section("azure").Key("cs_enabled").MustBool(false), 139 | AzureCSPreferredLocations: cfg.Section("azure").Key("cs_preferred_locations").Strings(","), 140 | AzureCSNbInstances: cfg.Section("azure").Key("cs_nb_instances").MustInt(5), //nolint:gomnd 141 | }, nil 142 | } 143 | 144 | // Parses the command-line arguments 145 | func parseFlags() utils.CommandParameters { 146 | var ( 147 | host string 148 | port int 149 | verbose1 bool 150 | verbose2 bool 151 | verbose3 bool 152 | exportCaCert bool 153 | configINIPath string 154 | ) 155 | 156 | flag.StringVar(&host, "host", "127.0.0.1", "Proxy host") 157 | flag.IntVar(&port, "port", 8080, "Proxy port") //nolint:revive,gomnd 158 | flag.BoolVar(&verbose1, "v", false, "Verbose mode") 159 | flag.BoolVar(&verbose2, "vv", false, "Very verbose mode") 160 | flag.BoolVar(&verbose3, "vvv", false, "Very very verbose mode") 161 | flag.BoolVar(&exportCaCert, "export-ca-cert", false, "Export CA cert and key") 162 | flag.StringVar(&configINIPath, "config", "config.ini", "Config INI file path (check zad for file format)") 163 | 164 | flag.Parse() 165 | 166 | return utils.CommandParameters{ 167 | Host: host, 168 | Port: port, 169 | Verbose1: verbose1 || verbose2 || verbose3, 170 | Verbose2: verbose2 || verbose3, 171 | Verbose3: verbose3, 172 | ExportCaCert: exportCaCert, 173 | ConfigINIPath: configINIPath, 174 | } 175 | } 176 | 177 | // Launches the summarize task and returns a bool pointer to stop it 178 | func launchSummarizeStateTask(allConfigs *utils.AllConfigs, providersLst []utils.Provider) *bool { 179 | var running bool 180 | 181 | if allConfigs.CommandParameters.Verbose1 || allConfigs.CommandParameters.Verbose2 || allConfigs.CommandParameters.Verbose3 { 182 | running = true 183 | 184 | go func() { 185 | for running { 186 | time.Sleep(utils.SummarizeStateInterval * time.Second) 187 | 188 | if running { // If it has been stopped during the time.sleep 189 | for _, provider := range providersLst { 190 | utils.Logger.Debug().Str("provider", provider.GetName()).Msg(provider.SummarizeState()) 191 | 192 | for _, launcher := range provider.GetLaunchers() { 193 | utils.Logger.Debug().Str("provider", provider.GetName()).Str("launcher", launcher.GetName()).Msg(" - " + launcher.SummarizeState()) 194 | } 195 | } 196 | } 197 | } 198 | }() 199 | } 200 | 201 | return &running 202 | } 203 | 204 | // Launches the proxy with the given configuration and providers 205 | func launchProxy(ctx context.Context, allConfigs *utils.AllConfigs, providersLst []utils.Provider) (*http.Server, string, error) { 206 | prxy, proxyErr := proxy.CreateProxy(ctx, allConfigs, providersLst) 207 | 208 | host := allConfigs.CommandParameters.Host 209 | 210 | port := allConfigs.CommandParameters.Port 211 | 212 | if port == 0 { 213 | port = 8080 //nolint:revive 214 | } 215 | 216 | listenAddress := fmt.Sprintf("%s:%d", host, port) 217 | 218 | if proxyErr != nil { 219 | return nil, listenAddress, proxyErr 220 | } 221 | 222 | srv := http.Server{Addr: listenAddress, Handler: prxy} //nolint:gosec 223 | 224 | // Starts the Proxy server 225 | go func() { 226 | utils.Logger.Info().Str("listenAddress", listenAddress).Msg("Proxy is running.") 227 | 228 | err := srv.ListenAndServe() 229 | 230 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 231 | utils.Logger.Error().Str("listenAddress", listenAddress).Err(err).Msg("An error happened while launching the proxy.") 232 | 233 | providers.ClearProviders(providersLst) 234 | 235 | os.Exit(1) //nolint:revive 236 | } 237 | }() 238 | 239 | return &srv, listenAddress, nil 240 | } 241 | -------------------------------------------------------------------------------- /proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "fmt" 9 | "io" 10 | "ipspinner/providers" 11 | "ipspinner/utils" 12 | "net/http" 13 | "strconv" 14 | "time" 15 | 16 | "github.com/elazarl/goproxy" 17 | ) 18 | 19 | // Setups the proxy certificate authority 20 | func setupProxyCA(caCert, caKey []byte) error { 21 | goproxyCa, err := tls.X509KeyPair(caCert, caKey) 22 | 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if goproxyCa.Leaf, err = x509.ParseCertificate(goproxyCa.Certificate[0]); err != nil { 28 | return err 29 | } 30 | 31 | goproxy.GoproxyCa = goproxyCa 32 | goproxy.OkConnect = &goproxy.ConnectAction{Action: goproxy.ConnectAccept, TLSConfig: goproxy.TLSConfigFromCA(&goproxyCa)} 33 | goproxy.MitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectMitm, TLSConfig: goproxy.TLSConfigFromCA(&goproxyCa)} 34 | goproxy.HTTPMitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectHTTPMitm, TLSConfig: goproxy.TLSConfigFromCA(&goproxyCa)} 35 | goproxy.RejectConnect = &goproxy.ConnectAction{Action: goproxy.ConnectReject, TLSConfig: goproxy.TLSConfigFromCA(&goproxyCa)} 36 | 37 | return nil 38 | } 39 | 40 | // Retrieves or generates CA certificates for the proxy 41 | func prepareProxyCertificates(allConfigs *utils.AllConfigs) error { 42 | var caCert []byte 43 | 44 | var caKey []byte 45 | 46 | // If no CA certificate and key have been given 47 | if allConfigs.ProxyConfig.CaCertFile == "" || allConfigs.ProxyConfig.CaCertKeyFile == "" { 48 | var caErr error 49 | 50 | // It generates a random self-signed CA certificate and its key 51 | utils.Logger.Info().Msg("Generating a new CA certificate.") 52 | 53 | caCert, caKey, caErr = utils.GenerateRSACACertificate() 54 | 55 | if caErr != nil { 56 | return caErr 57 | } 58 | } else { // Otherwise, it tries to read the provided certicate and key 59 | var caCertErr error 60 | var caKeyErr error 61 | 62 | utils.Logger.Info().Str("caCertPath", allConfigs.ProxyConfig.CaCertFile).Str("caCertKeyPath", allConfigs.ProxyConfig.CaCertKeyFile).Msg("Retrieving the provided CA certificate.") 63 | 64 | caCert, caCertErr = utils.ReadFileContent(allConfigs.ProxyConfig.CaCertFile) 65 | 66 | if caCertErr != nil { 67 | return caCertErr 68 | } 69 | 70 | caKey, caKeyErr = utils.ReadFileContent(allConfigs.ProxyConfig.CaCertKeyFile) 71 | 72 | if caKeyErr != nil { 73 | return caKeyErr 74 | } 75 | } 76 | 77 | // It setups the certificate and key on the proxy 78 | err := setupProxyCA(caCert, caKey) 79 | 80 | if err != nil { 81 | return err 82 | } 83 | 84 | // If the user has asked for exporting the certificate and the key 85 | if allConfigs.CommandParameters.ExportCaCert { 86 | exportedCertName := "ipspinner-ca-cert.pem" 87 | exportedCertKeyName := "ipspinner-ca-cert-key.pem" 88 | 89 | utils.Logger.Info().Str("exportedCaCertPath", exportedCertName).Str("exportedCaCertKeyPath", exportedCertKeyName).Msg("Exporting the CA certificate.") 90 | 91 | caCertErr := utils.WriteFileContent(exportedCertName, caCert) 92 | 93 | if caCertErr != nil { 94 | return caCertErr 95 | } 96 | 97 | caCertKeyErr := utils.WriteFileContent(exportedCertKeyName, caKey) 98 | 99 | if caCertKeyErr != nil { 100 | return caCertKeyErr 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | // Returns the OnRequest handler 108 | func generateOnRequestHandler(ctx context.Context, providersLst []utils.Provider, allConfigs *utils.AllConfigs) func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { 109 | requestCount := 0 110 | 111 | whitelistHosts := utils.ParseHostsFile(allConfigs.ProxyConfig.WhitelistHostsFile) 112 | blacklistHosts := utils.ParseHostsFile(allConfigs.ProxyConfig.BlacklistHostsFile) 113 | 114 | if len(whitelistHosts) > 0 { 115 | utils.Logger.Info().Int("nbHostsInWhitelist", len(whitelistHosts)).Msg("Hosts mentioned in the whitelist have been loaded.") 116 | 117 | if len(blacklistHosts) > 0 { 118 | utils.Logger.Warn().Msg("The blacklist has been ignored because a whitelist has been given.") 119 | } 120 | } else if len(blacklistHosts) > 0 { 121 | utils.Logger.Info().Int("nbHostsInBlacklist", len(blacklistHosts)).Msg("Hosts mentioned in the blacklist have been loaded.") 122 | } 123 | 124 | userAgents := []string{} 125 | 126 | if len(allConfigs.ProxyConfig.UserAgentsFile) > 0 { 127 | userAgentsLines, userAgentsErr := utils.ReadFileLines(allConfigs.ProxyConfig.UserAgentsFile) 128 | 129 | if userAgentsErr != nil { 130 | utils.Logger.Warn().Err(userAgentsErr).Msg("Can not read user agents file.") 131 | } 132 | 133 | userAgents = userAgentsLines 134 | } 135 | 136 | return func(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) { 137 | if req == nil || req.URL == nil { 138 | return req, nil 139 | } 140 | 141 | // If the whitelist is set 142 | if len(whitelistHosts) > 0 { 143 | if !utils.DoesURLListContainsBaseURL(whitelistHosts, req.URL) { // Checks if the request URL is not in the whitelist 144 | utils.Logger.Warn().Str("host", req.URL.String()).Msg("Can not send a request to this host because it is not mentioned in the whitelist.") 145 | 146 | return nil, goproxy.NewResponse(req, 147 | goproxy.ContentTypeText, 148 | http.StatusBadGateway, 149 | "PROXY ERROR: Can not send a request to this host because it is not mentioned in the whitelist.") 150 | } 151 | } else if len(blacklistHosts) > 0 { // if the blacklist is set 152 | if utils.DoesURLListContainsBaseURL(blacklistHosts, req.URL) { // Checks if the request URL is in the blacklist 153 | utils.Logger.Warn().Str("host", req.URL.String()).Msg("Can not send a request to this host because it is mentioned in the blacklist.") 154 | 155 | return nil, goproxy.NewResponse(req, 156 | goproxy.ContentTypeText, 157 | http.StatusBadGateway, 158 | "PROXY ERROR: Can not send a request to this host because it is mentioned in the blacklist.") 159 | } 160 | } 161 | 162 | // Waits for an available launcher 163 | var maxRep = 1000 / 100 * allConfigs.ProxyConfig.WaitForLauncherAvailableTimeout // max WaitForLauncherAvailable seconds 164 | 165 | nbRep := 0 166 | 167 | for len(providers.GetAllAvailableLaunchers(providersLst)) == 0 { 168 | nbRep++ 169 | 170 | if nbRep >= maxRep { 171 | return nil, goproxy.NewResponse(req, 172 | goproxy.ContentTypeText, 173 | http.StatusBadGateway, 174 | "PROXY ERROR: Timeout - no launcher seem to be available.") 175 | } 176 | 177 | time.Sleep(100 * time.Millisecond) //nolint:gomnd 178 | } 179 | 180 | // It takes a random launcher among all available launchers 181 | launcher := utils.RandomElementInSlice(providers.GetAllAvailableLaunchers(providersLst)) 182 | 183 | headers := map[string]any{} 184 | 185 | for name, headersLst := range req.Header { 186 | for _, h := range headersLst { 187 | headers[name] = h 188 | } 189 | } 190 | 191 | if len(userAgents) > 0 { 192 | headers["User-Agent"] = utils.RandomElementInSlice(userAgents) 193 | } 194 | 195 | body, bodyErr := io.ReadAll(req.Body) 196 | 197 | if bodyErr != nil { 198 | utils.Logger.Error().Err(bodyErr).Msg("Error reading request body.") 199 | 200 | return nil, goproxy.NewResponse(req, 201 | goproxy.ContentTypeText, 202 | http.StatusBadGateway, 203 | "PROXY ERROR: Error reading request body: "+bodyErr.Error()) 204 | } 205 | 206 | respData, respHeaderCustom, respDataErr := (*launcher).SendRequest(ctx, utils.HTTPRequestData{ 207 | URL: req.URL, 208 | Method: req.Method, 209 | Headers: headers, 210 | Body: bytes.NewBuffer(body), 211 | }, allConfigs) 212 | 213 | if respDataErr != nil { 214 | utils.Logger.Error().Err(respDataErr).Msg("Error while processing request.") 215 | 216 | return nil, goproxy.NewResponse(req, 217 | goproxy.ContentTypeText, 218 | http.StatusBadGateway, 219 | "PROXY ERROR: Error while processing request: "+respDataErr.Error()) 220 | } 221 | 222 | resp := goproxy.NewResponse(req, 223 | utils.GetOrDefault(respData.Headers, "Content-Type", goproxy.ContentTypeText).(string), 224 | respData.StatusCode, 225 | string(respData.Body)) 226 | 227 | for headerName, headerVal := range respData.Headers { 228 | resp.Header.Set(headerName, headerVal.(string)) 229 | } 230 | 231 | if allConfigs.ProxyConfig.DebugResponseHeaders { 232 | resp.Header.Set(utils.IPSpinnerResponseHeaderPrefix+"Provider", (*launcher).GetProvider().GetName()) 233 | resp.Header.Set(utils.IPSpinnerResponseHeaderPrefix+"Launcher", (*launcher).GetName()) 234 | resp.Header.Set(utils.IPSpinnerResponseHeaderPrefix+"Provider-NbTotalReqSent", strconv.Itoa((*launcher).GetProvider().GetNbTotalReqSent())) 235 | resp.Header.Set(utils.IPSpinnerResponseHeaderPrefix+"Launcher-NbTotalReqSent", strconv.Itoa((*launcher).GetNbTotalReqSent())) 236 | 237 | if respHeaderCustom != "" { 238 | resp.Header.Set(utils.IPSpinnerResponseHeaderPrefix+"Launcher-Custom", respHeaderCustom) 239 | } 240 | } 241 | 242 | requestCount++ 243 | 244 | utils.Logger.Trace().Str("from", req.URL.String()).Str("to-provider", (*launcher).GetProvider().GetName()).Str("to-launcher", (*launcher).GetName()).Str("method", req.Method).Msg(fmt.Sprintf("Redirecting request #%d.", requestCount)) 245 | 246 | return req, resp 247 | } 248 | } 249 | 250 | // Creates a proxy instance 251 | func CreateProxy(ctx context.Context, allConfigs *utils.AllConfigs, providersLst []utils.Provider) (*goproxy.ProxyHttpServer, error) { 252 | prepareErr := prepareProxyCertificates(allConfigs) 253 | 254 | if prepareErr != nil { 255 | return nil, prepareErr 256 | } 257 | 258 | // It instantiates the goproxy instance 259 | proxy := goproxy.NewProxyHttpServer() 260 | 261 | proxy.OnRequest().HandleConnect(goproxy.AlwaysMitm) 262 | 263 | proxy.OnRequest().DoFunc(generateOnRequestHandler(ctx, providersLst, allConfigs)) //nolint:bodyclose 264 | 265 | proxy.Verbose = allConfigs.CommandParameters.Verbose3 266 | 267 | return proxy, nil 268 | } 269 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0 h1:GJHeeA2N7xrG3q30L2UXDyuWRzDM900/65j70wcM4Ww= 2 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= 3 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= 4 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= 5 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= 6 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= 7 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= 8 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= 9 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= 10 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= 11 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= 12 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= 13 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= 14 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 15 | github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= 16 | github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= 17 | github.com/aws/aws-sdk-go-v2/config v1.27.10 h1:PS+65jThT0T/snC5WjyfHHyUgG+eBoupSDV+f838cro= 18 | github.com/aws/aws-sdk-go-v2/config v1.27.10/go.mod h1:BePM7Vo4OBpHreKRUMuDXX+/+JWP38FLkzl5m27/Jjs= 19 | github.com/aws/aws-sdk-go-v2/credentials v1.17.10 h1:qDZ3EA2lv1KangvQB6y258OssCHD0xvaGiEDkG4X/10= 20 | github.com/aws/aws-sdk-go-v2/credentials v1.17.10/go.mod h1:6t3sucOaYDwDssHQa0ojH1RpmVmF5/jArkye1b2FKMI= 21 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4= 22 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= 23 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= 24 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= 25 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= 26 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= 27 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= 28 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= 29 | github.com/aws/aws-sdk-go-v2/service/apigateway v1.23.6 h1:YZ4tYuH59Xd5q3bYmDqKXt8fQVJ19WPoq4lKzW1iLMg= 30 | github.com/aws/aws-sdk-go-v2/service/apigateway v1.23.6/go.mod h1:3h9BDpayKgNNrpHZBvL7gCIeikqiE7oBxGGcrzmtLAM= 31 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= 32 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= 33 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo= 34 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk= 35 | github.com/aws/aws-sdk-go-v2/service/sso v1.20.4 h1:WzFol5Cd+yDxPAdnzTA5LmpHYSWinhmSj4rQChV0ee8= 36 | github.com/aws/aws-sdk-go-v2/service/sso v1.20.4/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= 37 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE= 38 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak= 39 | github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU= 40 | github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= 41 | github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= 42 | github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= 43 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 44 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 45 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 46 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 47 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 48 | github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 h1:m62nsMU279qRD9PQSWD1l66kmkXzuYcnVJqL4XLeV2M= 49 | github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 50 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= 51 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= 52 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 53 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 54 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 55 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 56 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 57 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 58 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 59 | github.com/jefflinse/githubsecret v1.0.2 h1:eq9HVjLvljN7PdpdkttU+M7gtsehzW8pChHpcP3kP3E= 60 | github.com/jefflinse/githubsecret v1.0.2/go.mod h1:+5nqeYtLIZ6tdFwP1LD38A3JnvyHHcVPVUYvHVRZr9k= 61 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 62 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 63 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 64 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 65 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 66 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 67 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 68 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 69 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 70 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 71 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 72 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 73 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 74 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 75 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 76 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 77 | github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= 78 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 79 | github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= 80 | github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 81 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 82 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 83 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 84 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 85 | golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 86 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 87 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 88 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 89 | golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 90 | golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 91 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 92 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 93 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 98 | golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 99 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 100 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 101 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 102 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 103 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 106 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 107 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 108 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 110 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 111 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 112 | -------------------------------------------------------------------------------- /providers/github/sdk.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "ipspinner/utils" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/jefflinse/githubsecret" 12 | ) 13 | 14 | type WorkflowRun struct { 15 | Name string 16 | Status string 17 | ID int64 18 | } 19 | 20 | type WorkflowJob struct { 21 | Name string 22 | WorkflowName string 23 | Status string 24 | ID int64 25 | RunID int64 26 | Steps []WorkflowJobStep 27 | } 28 | 29 | type WorkflowJobStep struct { 30 | Name string 31 | Status string 32 | } 33 | 34 | func (infos Infos) getHeaders() map[string]any { 35 | return map[string]any{ 36 | "Accept": infos.Accept, 37 | "X-GitHub-Api-Version": infos.APIVersion, 38 | "Authorization": fmt.Sprintf("Bearer %s", infos.Token), 39 | } 40 | } 41 | 42 | // Creates a repository with the provided information 43 | func CreateRepository(infos Infos, repositoryName string) error { 44 | reqBody := map[string]any{ 45 | "name": repositoryName, 46 | } 47 | 48 | githubURL, githubURLErr := url.Parse("https://api.github.com/user/repos") 49 | 50 | if githubURLErr != nil { 51 | return githubURLErr 52 | } 53 | 54 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 55 | URL: githubURL, 56 | Method: "POST", 57 | Headers: infos.getHeaders(), 58 | Body: reqBody, 59 | }) 60 | 61 | if respErr != nil { 62 | return respErr 63 | } 64 | 65 | if resp.StatusCode != http.StatusCreated { 66 | return errors.New(utils.GetOrDefault(resp.Body, "message", "Can not create the repository.").(string)) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // Adds a file to the repository (file content encoded in base64) 73 | func AddFileToRepository(infos Infos, repositoryName, path, fileContentB64, commitMessage string) error { 74 | reqBody := map[string]any{ 75 | "message": commitMessage, 76 | "content": fileContentB64, 77 | "private": true, 78 | } 79 | 80 | path = strings.TrimPrefix(path, "/") 81 | 82 | githubURL, githubURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s", infos.Username, repositoryName, path)) 83 | 84 | if githubURLErr != nil { 85 | return githubURLErr 86 | } 87 | 88 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 89 | URL: githubURL, 90 | Method: "PUT", 91 | Headers: infos.getHeaders(), 92 | Body: reqBody, 93 | }) 94 | 95 | if respErr != nil { 96 | return respErr 97 | } 98 | 99 | if resp.StatusCode != http.StatusCreated { 100 | return errors.New(utils.GetOrDefault(resp.Body, "message", "Can not add the file to the directory.").(string)) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // Dispatches the provided workflow 107 | func DispatchWorkflow(infos Infos, repositoryName string, inputs map[string]any) (string, error) { 108 | runIdentifier := utils.GenerateRandomPrefix(10) //nolint:revive,gomnd // used to retrieve the workflow ID later as it is launched asynchronously : https://stackoverflow.com/questions/69479400/get-run-id-after-triggering-a-github-workflow-dispatch-event 109 | 110 | inputs["runIdentifier"] = runIdentifier 111 | 112 | reqBody := map[string]any{ 113 | "ref": "main", 114 | "inputs": inputs, 115 | } 116 | 117 | githubURL, githubURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/workflows/sprayer.yml/dispatches", infos.Username, repositoryName)) 118 | 119 | if githubURLErr != nil { 120 | return runIdentifier, githubURLErr 121 | } 122 | 123 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 124 | URL: githubURL, 125 | Method: "POST", 126 | Headers: infos.getHeaders(), 127 | Body: reqBody, 128 | }) 129 | 130 | if respErr != nil { 131 | return runIdentifier, respErr 132 | } 133 | 134 | if resp.StatusCode != http.StatusNoContent { 135 | return runIdentifier, errors.New(utils.GetOrDefault(resp.Body, "message", "Can not dispatch the workflow.").(string)) 136 | } 137 | 138 | return runIdentifier, nil 139 | } 140 | 141 | // Retrieves all workflow runs in the provided repository 142 | func GetWorkflowRuns(infos Infos, repositoryName string) ([]WorkflowRun, error) { 143 | allWorkflowRuns := []WorkflowRun{} 144 | 145 | perPage := 100 //nolint:revive 146 | page := 1 147 | maxPage := 1 148 | 149 | for page <= maxPage { 150 | githubURL, githubURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/runs?page=%d&per_page=%d", infos.Username, repositoryName, page, perPage)) 151 | 152 | if githubURLErr != nil { 153 | return allWorkflowRuns, githubURLErr 154 | } 155 | 156 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 157 | URL: githubURL, 158 | Method: "GET", 159 | Headers: infos.getHeaders(), 160 | Body: nil, 161 | }) 162 | 163 | if respErr != nil { 164 | return allWorkflowRuns, respErr 165 | } 166 | 167 | if resp.StatusCode != http.StatusOK { 168 | return allWorkflowRuns, errors.New(utils.GetOrDefault(resp.Body, "message", "Can not list workflow runs.").(string)) 169 | } 170 | 171 | for _, rawWorkflowRun := range resp.Body["workflow_runs"].([]any) { 172 | allWorkflowRuns = append(allWorkflowRuns, WorkflowRun{ 173 | ID: int64(rawWorkflowRun.(map[string]any)["id"].(float64)), 174 | Name: rawWorkflowRun.(map[string]any)["name"].(string), 175 | Status: rawWorkflowRun.(map[string]any)["status"].(string), 176 | }) 177 | } 178 | 179 | maxPage = int(utils.GetOrDefault(resp.Body, "total_count", 1).(float64))/perPage + 1 180 | 181 | page++ 182 | } 183 | 184 | return allWorkflowRuns, nil 185 | } 186 | 187 | // Retrieves all workflow jobs in the provided repository / workflow 188 | func GetWorkflowJobs(infos Infos, repositoryName string, workflowID int64) ([]WorkflowJob, error) { 189 | allWorkflowJobs := []WorkflowJob{} 190 | 191 | perPage := 100 //nolint:revive 192 | page := 1 193 | maxPage := 1 194 | 195 | for page <= maxPage { 196 | githubURL, githubURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/runs/%d/jobs?page=%d&per_page=%d", infos.Username, repositoryName, workflowID, page, perPage)) 197 | 198 | if githubURLErr != nil { 199 | return allWorkflowJobs, githubURLErr 200 | } 201 | 202 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 203 | URL: githubURL, 204 | Method: "GET", 205 | Headers: infos.getHeaders(), 206 | Body: nil, 207 | }) 208 | 209 | if respErr != nil { 210 | return allWorkflowJobs, respErr 211 | } 212 | 213 | if resp.StatusCode != http.StatusOK { 214 | return allWorkflowJobs, errors.New(utils.GetOrDefault(resp.Body, "message", "Can not list workflow jobs.").(string)) 215 | } 216 | 217 | for _, rawWorkflowJob := range resp.Body["jobs"].([]any) { 218 | allWorkflowJobSteps := []WorkflowJobStep{} 219 | 220 | for _, rawWorkflowJobStep := range rawWorkflowJob.(map[string]any)["steps"].([]any) { 221 | allWorkflowJobSteps = append(allWorkflowJobSteps, WorkflowJobStep{ 222 | Name: rawWorkflowJobStep.(map[string]any)["name"].(string), 223 | Status: rawWorkflowJobStep.(map[string]any)["status"].(string), 224 | }) 225 | } 226 | 227 | allWorkflowJobs = append(allWorkflowJobs, WorkflowJob{ 228 | ID: int64(rawWorkflowJob.(map[string]any)["id"].(float64)), 229 | RunID: int64(rawWorkflowJob.(map[string]any)["run_id"].(float64)), 230 | Name: rawWorkflowJob.(map[string]any)["name"].(string), 231 | Status: rawWorkflowJob.(map[string]any)["status"].(string), 232 | WorkflowName: rawWorkflowJob.(map[string]any)["name"].(string), 233 | Steps: allWorkflowJobSteps, 234 | }) 235 | } 236 | 237 | maxPage = int(utils.GetOrDefault(resp.Body, "total_count", 1).(float64))/perPage + 1 238 | 239 | page++ 240 | } 241 | 242 | return allWorkflowJobs, nil 243 | } 244 | 245 | // Retrieves the logs of the provided workflow job 246 | func GetWorkflowJobLogs(infos Infos, repositoryName string, jobID int64) ([]string, error) { 247 | githubURL, githubURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/jobs/%d/logs", infos.Username, repositoryName, jobID)) 248 | 249 | if githubURLErr != nil { 250 | return []string{}, githubURLErr 251 | } 252 | 253 | resp, respErr := utils.SendRequest(utils.HTTPRequestData{ 254 | URL: githubURL, 255 | Method: "GET", 256 | Headers: infos.getHeaders(), 257 | FollowRedirections: true, 258 | Body: nil, 259 | }) 260 | 261 | if respErr != nil { 262 | return []string{}, respErr 263 | } 264 | 265 | if resp.StatusCode != http.StatusOK { 266 | return []string{}, errors.New(string(resp.Body)) 267 | } 268 | 269 | return strings.Split(string(resp.Body), "\n"), nil 270 | } 271 | 272 | // Deletes the provided workflow run 273 | func DeleteWorkflowRun(infos Infos, repositoryName string, runID int64) error { 274 | githubURL, githubURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/runs/%d", infos.Username, repositoryName, runID)) 275 | 276 | if githubURLErr != nil { 277 | return githubURLErr 278 | } 279 | 280 | resp, respErr := utils.SendRequest(utils.HTTPRequestData{ 281 | URL: githubURL, 282 | Method: "DELETE", 283 | Headers: infos.getHeaders(), 284 | Body: nil, 285 | }) 286 | 287 | if respErr != nil { 288 | return respErr 289 | } 290 | 291 | if resp.StatusCode != http.StatusNoContent { 292 | return errors.New(string(resp.Body)) 293 | } 294 | 295 | return nil 296 | } 297 | 298 | // Deletes the provided repository 299 | func DeleteRepository(infos Infos, repositoryName string) error { 300 | githubURL, githubURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s", infos.Username, repositoryName)) 301 | 302 | if githubURLErr != nil { 303 | return githubURLErr 304 | } 305 | 306 | resp, respErr := utils.SendRequest(utils.HTTPRequestData{ 307 | URL: githubURL, 308 | Method: "DELETE", 309 | Headers: infos.getHeaders(), 310 | Body: nil, 311 | }) 312 | 313 | if respErr != nil { 314 | return respErr 315 | } 316 | 317 | if resp.StatusCode != http.StatusNoContent { 318 | return fmt.Errorf("can not delete the repository, status code: %d", resp.StatusCode) 319 | } 320 | 321 | return nil 322 | } 323 | 324 | // Creates or updates a GitHub repository secret 325 | func CreateOrUpdateRepositorySecret(infos Infos, repositoryName, secretName, secretValue string) error { 326 | // First, it retrieves the repository public key 327 | githubPublicKeyURL, githubPublicKeyURLErr := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/secrets/public-key", infos.Username, repositoryName)) 328 | 329 | if githubPublicKeyURLErr != nil { 330 | return githubPublicKeyURLErr 331 | } 332 | 333 | respPublicKey, respPublicKeyErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 334 | URL: githubPublicKeyURL, 335 | Method: "GET", 336 | Headers: infos.getHeaders(), 337 | Body: nil, 338 | }) 339 | 340 | if respPublicKeyErr != nil { 341 | return respPublicKeyErr 342 | } 343 | 344 | if respPublicKey.StatusCode != http.StatusOK { 345 | return errors.New(utils.GetOrDefault(respPublicKey.Body, "message", "Can not retrieve the repository public key (1).").(string)) 346 | } 347 | 348 | key, keyOk := respPublicKey.Body["key"].(string) 349 | keyID, keyIDOk := respPublicKey.Body["key_id"].(string) 350 | 351 | if !keyOk || !keyIDOk { 352 | return errors.New(utils.GetOrDefault(respPublicKey.Body, "message", "Can not retrieve the repository public key (2).").(string)) 353 | } 354 | 355 | secretEncrypted, secretEncryptedErr := githubsecret.Encrypt(key, secretValue) 356 | 357 | if secretEncryptedErr != nil { 358 | return secretEncryptedErr 359 | } 360 | 361 | githubCreateOrUpdateURL, githubCreateOrUpdate := url.Parse(fmt.Sprintf("https://api.github.com/repos/%s/%s/actions/secrets/%s", infos.Username, repositoryName, secretName)) 362 | 363 | if githubCreateOrUpdate != nil { 364 | return githubCreateOrUpdate 365 | } 366 | 367 | reqCreateOrUpdateBody := map[string]any{ 368 | "encrypted_value": secretEncrypted, 369 | "key_id": keyID, 370 | } 371 | 372 | respCreateOrUpdate, respCreateOrUpdateErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 373 | URL: githubCreateOrUpdateURL, 374 | Method: "PUT", 375 | Headers: infos.getHeaders(), 376 | Body: reqCreateOrUpdateBody, 377 | }) 378 | 379 | if respCreateOrUpdateErr != nil { 380 | return respCreateOrUpdateErr 381 | } 382 | 383 | if respCreateOrUpdate.StatusCode != http.StatusCreated { 384 | return fmt.Errorf("can not update repository secrets, status code: %d", respCreateOrUpdate.StatusCode) 385 | } 386 | 387 | return nil 388 | } 389 | -------------------------------------------------------------------------------- /providers/github/repository.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "ipspinner/utils" 9 | "net/url" 10 | "slices" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type Repository struct { 17 | provider *GitHub 18 | sendingRequests map[string]*Worker 19 | name string 20 | aesKeyHex string 21 | nbTotalRequestsSent int 22 | stopped bool 23 | } 24 | 25 | type Worker struct { 26 | closed bool 27 | channel chan WorkerResponse 28 | } 29 | 30 | type WorkerResponse struct { 31 | ResponseData utils.HTTPResponseData 32 | Error error 33 | } 34 | 35 | func CreateRepositoryLauncher(provider *GitHub) (*Repository, error) { 36 | instance := Repository{ 37 | name: strings.ReplaceAll(utils.GenerateRandomSentence(3), " ", "-"), 38 | provider: provider, 39 | nbTotalRequestsSent: 0, 40 | sendingRequests: map[string]*Worker{}, 41 | stopped: false, 42 | aesKeyHex: "", 43 | } 44 | 45 | utils.Logger.Info().Str("launcher", instance.GetName()).Msg("Creating launcher.") 46 | 47 | aesKey, aesKeyErr := utils.Aes256GenerateKey() 48 | 49 | if aesKeyErr != nil { 50 | return &instance, aesKeyErr 51 | } 52 | 53 | instance.aesKeyHex = aesKey 54 | 55 | // Creates the github repository 56 | createErr, addErr := instance.PrepareSprayerRepository() 57 | 58 | if createErr != nil { 59 | return &instance, createErr 60 | } 61 | 62 | // If the repository has been successfully created but the files could not have been added => it deletes the repository 63 | if addErr != nil { 64 | deleteErr := DeleteRepository(instance.provider.GetInfos(), instance.GetName()) 65 | 66 | if deleteErr != nil { 67 | utils.Logger.Error().Err(deleteErr).Str("repositoryName", instance.GetName()).Msg("Cannot delete the previously created repository.") 68 | } 69 | 70 | return &instance, addErr 71 | } 72 | 73 | // Launches the go routine for constantly reading workflow runs 74 | go instance.LoopWorkflows() 75 | 76 | return &instance, nil 77 | } 78 | 79 | func (instance Repository) GetName() string { 80 | return instance.name 81 | } 82 | 83 | func (instance *Repository) GetProvider() utils.Provider { 84 | return instance.provider 85 | } 86 | 87 | func (instance Repository) GetNbTotalReqSent() int { 88 | return instance.nbTotalRequestsSent 89 | } 90 | 91 | func (instance Repository) SummarizeState() string { 92 | return fmt.Sprintf("Launcher %s : nbTotalRequestsSent=%d, name=%s", instance.GetName(), instance.GetNbTotalReqSent(), instance.GetName()) 93 | } 94 | 95 | //nolint:revive 96 | func (instance *Repository) PreloadHosts(ctx context.Context, allPreloadHosts []*url.URL) { 97 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("This provider cannot preload hosts.") 98 | } 99 | 100 | func (instance *Repository) SendRequest(ctx context.Context, reqData utils.HTTPRequestData, allConfigs *utils.AllConfigs) (utils.HTTPResponseData, string, error) { //nolint:revive 101 | headersStr := "" 102 | 103 | if reqData.Headers != nil { 104 | for headerKey, headerVal := range reqData.Headers { 105 | headersStr += headerKey + "\n" + fmt.Sprintf("%v", headerVal) + "\n" 106 | } 107 | 108 | if len(headersStr) > 0 { 109 | headersStr = headersStr[:(len(headersStr) - 1)] 110 | } 111 | } 112 | 113 | bodyStr := "" 114 | 115 | if reqData.Body != nil { 116 | bodyStr = reqData.Body.String() 117 | } 118 | 119 | encryptedMethod, encryptedMethodErr := utils.Aes256Encrypt(reqData.Method, instance.aesKeyHex) 120 | if encryptedMethodErr != nil { 121 | return utils.HTTPResponseData{}, "", encryptedMethodErr 122 | } 123 | 124 | encryptedURL, encryptedURLErr := utils.Aes256Encrypt(reqData.URL.String(), instance.aesKeyHex) 125 | if encryptedURLErr != nil { 126 | return utils.HTTPResponseData{}, "", encryptedURLErr 127 | } 128 | 129 | encryptedHeaders, encryptedHeadersErr := utils.Aes256Encrypt(headersStr, instance.aesKeyHex) 130 | if encryptedHeadersErr != nil { 131 | return utils.HTTPResponseData{}, "", encryptedHeadersErr 132 | } 133 | 134 | encryptedBody, encryptedBodyErr := utils.Aes256Encrypt(bodyStr, instance.aesKeyHex) 135 | if encryptedBodyErr != nil { 136 | return utils.HTTPResponseData{}, "", encryptedBodyErr 137 | } 138 | 139 | runIdentifier, dispatchErr := DispatchWorkflow( 140 | instance.provider.GetInfos(), 141 | instance.GetName(), 142 | map[string]any{ 143 | "methodEnc": encryptedMethod, 144 | "urlEnc": encryptedURL, 145 | "headersEnc": encryptedHeaders, 146 | "bodyEnc": encryptedBody, 147 | }) 148 | 149 | if dispatchErr != nil { 150 | return utils.HTTPResponseData{}, "", dispatchErr 151 | } 152 | 153 | worker := CreateWorker() 154 | 155 | instance.sendingRequests[runIdentifier] = &worker 156 | 157 | instance.nbTotalRequestsSent++ 158 | 159 | githubResponse := <-worker.GetChannel() 160 | 161 | worker.Close() 162 | 163 | return githubResponse.ResponseData, fmt.Sprintf("runIdentifier=%s", runIdentifier), githubResponse.Error 164 | } 165 | 166 | //nolint:revive 167 | func (instance *Repository) IsAvailable() bool { 168 | return true 169 | } 170 | 171 | func (instance *Repository) IsStopped() bool { 172 | return instance.stopped 173 | } 174 | 175 | func (instance *Repository) Clear() bool { 176 | utils.Logger.Info().Str("launcher", instance.GetName()).Msg("Clearing launcher.") 177 | 178 | deleteErr := DeleteRepository(instance.provider.GetInfos(), instance.GetName()) 179 | 180 | utils.Logger.Debug().Str("repositoryName", instance.GetName()).Msg("Deleting GitHub repository.") 181 | 182 | if deleteErr != nil { 183 | utils.Logger.Error().Err(deleteErr).Str("repositoryName", instance.GetName()).Msg("Error while deleting GitHub repository.") 184 | 185 | return false 186 | } 187 | 188 | instance.stopped = true 189 | 190 | return true 191 | } 192 | 193 | // This function is used to launch a loop to constantly read workflow runs and tries to retrieve its log for the responses' data 194 | func (instance *Repository) LoopWorkflows() { 195 | for !instance.stopped { 196 | // Large time to avoid reaching the GitHub API rate limit (5000 req/h) when there are no waiting requests 197 | time.Sleep(time.Duration(max(5, 10/(len(instance.sendingRequests)+1))) * time.Second) //nolint:gomnd 198 | 199 | runs, runsErr := GetWorkflowRuns(instance.provider.GetInfos(), instance.GetName()) 200 | 201 | if runsErr != nil { 202 | utils.Logger.Warn().Err(runsErr).Msg("Can not retrieve workflow runs.") 203 | 204 | continue 205 | } 206 | 207 | // For each run, it retrieves its jobs 208 | for _, run := range runs { 209 | if !slices.Contains([]string{"completed", "cancelled", "failure", "skipped", "success"}, run.Status) { 210 | continue 211 | } 212 | 213 | jobs, jobsErr := GetWorkflowJobs(instance.provider.GetInfos(), instance.GetName(), run.ID) 214 | 215 | if jobsErr != nil { 216 | utils.Logger.Warn().Err(jobsErr).Int64("runId", run.ID).Msg("Can not retrieve workflow jobs.") 217 | 218 | continue 219 | } 220 | 221 | var workflowWorker *Worker 222 | 223 | var runIdentifier string 224 | 225 | // It tries to identify which job corresponds to which request 226 | for _, job := range jobs { 227 | for _, step := range job.Steps { 228 | // If the step name (which will be in the Workflow ID Provider) matches one sending request => it retrieves the corresponding chanel (on which the proxy is waiting for this request) 229 | if currentworker, ok := instance.sendingRequests[step.Name]; ok { 230 | workflowWorker = currentworker 231 | runIdentifier = step.Name 232 | 233 | break 234 | } 235 | } 236 | 237 | if workflowWorker != nil { 238 | break 239 | } 240 | } 241 | 242 | // If the chanel has been found (the workflow has been identified) 243 | if workflowWorker != nil { 244 | for _, job := range jobs { 245 | if job.Name == "Request" { 246 | // It checks if the request job has finished 247 | if !slices.Contains([]string{"in_progress", "queued"}, job.Status) { 248 | // It starts in a go routine the response data extraction 249 | go func(jobID int64, runID int64, currentWorker *Worker) { 250 | githubResponse := instance.ExtractResponseDataFromJobLogs(jobID) 251 | 252 | delete(instance.sendingRequests, runIdentifier) 253 | 254 | if !currentWorker.IsClosed() { 255 | currentWorker.GetChannel() <- githubResponse 256 | } else { 257 | utils.Logger.Warn().Int64("runId", runID).Msg("The worker channel is closed (the client may have terminated the connection).") 258 | } 259 | 260 | deleteErr := DeleteWorkflowRun(instance.provider.GetInfos(), instance.GetName(), runID) 261 | 262 | if deleteErr != nil { 263 | utils.Logger.Warn().Err(deleteErr).Int64("runId", runID).Msg("Cannot delete the workflow run.") 264 | } 265 | }(job.ID, run.ID, workflowWorker) 266 | 267 | break 268 | } 269 | } 270 | } 271 | } 272 | } 273 | } 274 | } 275 | 276 | func (instance *Repository) PrepareSprayerRepository() (create, other error) { 277 | createErr := CreateRepository(instance.provider.GetInfos(), instance.GetName()) 278 | 279 | if createErr != nil { 280 | return createErr, nil 281 | } 282 | 283 | addAesKeyErr := CreateOrUpdateRepositorySecret(instance.provider.GetInfos(), instance.GetName(), "AES256_KEY_HEX", instance.aesKeyHex) 284 | 285 | if addAesKeyErr != nil { 286 | return nil, addAesKeyErr 287 | } 288 | 289 | addErr1 := AddFileToRepository(instance.provider.GetInfos(), instance.GetName(), "sprayer.py", SPRAYER_PY_FILE_BASE64, "add sprayer.yml") 290 | 291 | if addErr1 != nil { 292 | return nil, addErr1 293 | } 294 | 295 | addErr2 := AddFileToRepository(instance.provider.GetInfos(), instance.GetName(), "requirements.txt", REQUIREMENTS_TXT_FILE_BASE64, "add requirements.txt") 296 | 297 | if addErr2 != nil { 298 | return nil, addErr2 299 | } 300 | 301 | addErr3 := AddFileToRepository(instance.provider.GetInfos(), instance.GetName(), ".github/workflows/sprayer.yml", SPRAYER_YML_FILE_BASE64, "add sprayer.yml") 302 | 303 | if addErr3 != nil { 304 | return nil, addErr3 305 | } 306 | 307 | return nil, nil 308 | } 309 | 310 | func (instance *Repository) ExtractResponseDataFromJobLogs(jobID int64) WorkerResponse { 311 | logs, logsErr := GetWorkflowJobLogs(instance.provider.GetInfos(), instance.GetName(), jobID) 312 | 313 | if logsErr != nil { 314 | return WorkerResponse{ 315 | Error: logsErr, 316 | } 317 | } 318 | 319 | const RESP_ERROR_PREFIX = "RESP_ERR" //nolint:revive,stylecheck //uppercase more readable here 320 | 321 | const RESP_STATUS_PREFIX = "RESP_STATUS_ENCRYPTED_HEX" //nolint:revive,stylecheck //uppercase more readable here 322 | 323 | const RESP_HEADERS_PREFIX = "RESP_HEADERS_ENCRYPTED_HEX" //nolint:revive,stylecheck //uppercase more readable here 324 | 325 | const RESP_BODY_PREFIX = "RESP_BODY_ENCRYPTED_HEX" //nolint:revive,stylecheck //uppercase more readable here 326 | 327 | responseData := utils.HTTPResponseData{ 328 | StatusCode: -1, 329 | Headers: nil, 330 | Body: nil, 331 | } 332 | 333 | var statusCodeEncrStr *string 334 | 335 | var headersEncrStr *string 336 | 337 | var bodyEncrStr *string 338 | 339 | for _, log := range logs { 340 | logInfos := strings.Split(log, " ") 341 | 342 | if len(logInfos) < 3 { 343 | continue 344 | } 345 | 346 | if !slices.Contains([]string{RESP_STATUS_PREFIX, RESP_BODY_PREFIX, RESP_HEADERS_PREFIX, RESP_ERROR_PREFIX}, logInfos[1]) { 347 | continue 348 | } 349 | 350 | if logInfos[1] == RESP_ERROR_PREFIX { 351 | if len(logInfos) == 2 { 352 | return WorkerResponse{ 353 | Error: errors.New("an error happened while treating request"), 354 | } 355 | } 356 | 357 | decodedBytes, decodedBytesErr := base64.StdEncoding.DecodeString(logInfos[2]) 358 | 359 | if decodedBytesErr != nil { 360 | return WorkerResponse{ 361 | Error: decodedBytesErr, 362 | } 363 | } 364 | 365 | return WorkerResponse{ 366 | Error: errors.New(string(decodedBytes)), 367 | } 368 | } 369 | 370 | switch logInfos[1] { 371 | case RESP_STATUS_PREFIX: 372 | statusCodeEncrStr = &logInfos[2] 373 | case RESP_HEADERS_PREFIX: 374 | value := logInfos[2] 375 | 376 | if headersEncrStr != nil { 377 | value = *headersEncrStr + value 378 | } 379 | 380 | headersEncrStr = &value 381 | case RESP_BODY_PREFIX: 382 | value := logInfos[2] 383 | 384 | if bodyEncrStr != nil { 385 | value = *bodyEncrStr + value 386 | } 387 | 388 | bodyEncrStr = &value 389 | } 390 | } 391 | 392 | if statusCodeEncrStr != nil { 393 | data, dataErr := utils.Aes256Decrypt(*statusCodeEncrStr, instance.aesKeyHex) 394 | 395 | if dataErr != nil { 396 | return WorkerResponse{ 397 | Error: dataErr, 398 | } 399 | } 400 | 401 | decodedInt, decodedIntErr := strconv.Atoi(data) 402 | 403 | if decodedIntErr != nil { 404 | return WorkerResponse{ 405 | Error: decodedIntErr, 406 | } 407 | } 408 | 409 | responseData.StatusCode = decodedInt 410 | } 411 | 412 | if headersEncrStr != nil { 413 | data, dataErr := utils.Aes256Decrypt(*headersEncrStr, instance.aesKeyHex) 414 | 415 | if dataErr != nil { 416 | return WorkerResponse{ 417 | Error: dataErr, 418 | } 419 | } 420 | 421 | lines := strings.Split(data, "\n") 422 | dataMap := make(map[string]any) 423 | 424 | for i := 0; i < len(lines); i += 2 { 425 | if i+1 < len(lines) { 426 | key := lines[i] 427 | value := lines[i+1] 428 | dataMap[key] = value 429 | } 430 | } 431 | 432 | responseData.Headers = dataMap 433 | } 434 | 435 | if bodyEncrStr != nil { 436 | data, dataErr := utils.Aes256Decrypt(*bodyEncrStr, instance.aesKeyHex) 437 | 438 | if dataErr != nil { 439 | return WorkerResponse{ 440 | Error: dataErr, 441 | } 442 | } 443 | 444 | responseData.Body = []byte(data) 445 | } 446 | 447 | if responseData.StatusCode == -1 { 448 | return WorkerResponse{ 449 | Error: errors.New("no response found in the job logs"), 450 | } 451 | } 452 | 453 | return WorkerResponse{ 454 | Error: nil, 455 | ResponseData: responseData, 456 | } 457 | } 458 | 459 | func CreateWorker() Worker { 460 | return Worker{ 461 | channel: make(chan WorkerResponse), 462 | closed: false, 463 | } 464 | } 465 | 466 | func (instance *Worker) Close() { 467 | instance.closed = true 468 | } 469 | 470 | func (instance *Worker) IsClosed() bool { 471 | return instance.closed || instance.channel == nil 472 | } 473 | 474 | func (instance *Worker) GetChannel() chan WorkerResponse { 475 | return instance.channel 476 | } 477 | -------------------------------------------------------------------------------- /providers/azure/account.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "ipspinner/utils" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 13 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" 14 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 15 | ) 16 | 17 | type Account struct { 18 | username string 19 | ID string 20 | userPrincipalName string 21 | tenantID string 22 | tokenCredential *azcore.TokenCredential 23 | createdRoleAssignments []AccountRoleAssignment 24 | needToBeCleared bool 25 | } 26 | 27 | type AccountRoleAssignment struct { 28 | subscriptionID string 29 | roleAssignmentID string 30 | } 31 | 32 | // ID and userPrincipalName can be empty 33 | func ConnectAccount(email, password, tenantID string, needToBeCleared bool, id, userPrincipalName string) (Account, error) { 34 | var cred azcore.TokenCredential 35 | 36 | cred, credsErr := getAzIdentityFromNewUsernamePasswordCredential(email, password, tenantID) 37 | 38 | if credsErr != nil { 39 | return Account{}, credsErr 40 | } 41 | 42 | username := strings.Split(email, "@")[0] 43 | 44 | account := Account{ 45 | username: username, 46 | tokenCredential: &cred, 47 | tenantID: tenantID, 48 | needToBeCleared: needToBeCleared, 49 | ID: id, 50 | userPrincipalName: userPrincipalName, 51 | } 52 | 53 | return account, nil 54 | } 55 | 56 | func (account *Account) loadUserInformations() error { 57 | token, tokenErr := account.GetAccessToken([]string{"https://graph.microsoft.com/.default"}) 58 | 59 | if tokenErr != nil { 60 | return tokenErr 61 | } 62 | 63 | headers := map[string]any{ 64 | "Authorization": utils.PrepareBearerHeader(token.Token), 65 | "Content-Type": "application/json", 66 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 67 | } 68 | 69 | reqURL, reqURLErr := url.Parse("https://graph.microsoft.com/v1.0/me") 70 | 71 | if reqURLErr != nil { 72 | return reqURLErr 73 | } 74 | 75 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 76 | URL: reqURL, 77 | Method: "GET", 78 | Headers: headers, 79 | }) 80 | 81 | if respErr != nil { 82 | return respErr 83 | } 84 | 85 | if resp.StatusCode != http.StatusOK { 86 | return errors.New("cannot retrieve account's informations") 87 | } 88 | 89 | accountID, accountIDRes := resp.Body["id"].(string) 90 | if !accountIDRes { 91 | return errors.New("can not retrieve account's ID") 92 | } 93 | 94 | account.ID = accountID 95 | 96 | upn, upnRes := resp.Body["userPrincipalName"].(string) 97 | if !upnRes { 98 | return errors.New("can not retrieve account's UPN") 99 | } 100 | 101 | account.userPrincipalName = upn 102 | 103 | return nil 104 | } 105 | 106 | func (account *Account) GetID() (string, error) { 107 | if len(account.ID) > 0 { 108 | return account.ID, nil 109 | } 110 | 111 | err := account.loadUserInformations() 112 | 113 | return account.ID, err 114 | } 115 | 116 | func (account *Account) GetUserPrincipalName() (string, error) { 117 | if len(account.userPrincipalName) > 0 { 118 | return account.userPrincipalName, nil 119 | } 120 | 121 | err := account.loadUserInformations() 122 | 123 | return account.userPrincipalName, err 124 | } 125 | 126 | func (adminAccount *Account) AddAccountAsContributorToSubscription(userAccount *Account, subscriptionID string) error { //nolint:stylecheck 127 | token, tokenErr := adminAccount.GetAccessToken([]string{"https://management.core.windows.net/.default"}) 128 | 129 | if tokenErr != nil { 130 | return tokenErr 131 | } 132 | 133 | headers := map[string]any{ 134 | "Authorization": utils.PrepareBearerHeader(token.Token), 135 | "Accept": "*/*", 136 | "Content-Type": "application/json", 137 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 138 | } 139 | 140 | roleAssignmentID := utils.GenerateUUIDv4() 141 | 142 | reqURL, reqURLErr := url.Parse("https://management.azure.com/batch?api-version=2020-06-01") 143 | 144 | if reqURLErr != nil { 145 | return reqURLErr 146 | } 147 | 148 | userAccountID, userAccountIDErr := userAccount.GetID() 149 | 150 | if userAccountIDErr != nil { 151 | return userAccountIDErr 152 | } 153 | 154 | reqData := map[string]any{ 155 | "requests": []map[string]any{ 156 | { 157 | "content": map[string]any{ 158 | "Id": roleAssignmentID, 159 | "Properties": map[string]any{ 160 | "Id": roleAssignmentID, 161 | "PrincipalId": userAccountID, 162 | "PrincipalType": "User", 163 | "RoleDefinitionId": "/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c", 164 | "Scope": fmt.Sprintf("/subscriptions/%s", subscriptionID), 165 | "Condition": nil, 166 | "ConditionVersion": nil, 167 | }, 168 | }, 169 | "httpMethod": "PUT", 170 | "name": "b131bce0-1e07-4786-a666-2fa3c7235004", 171 | "requestHeaderDetails": map[string]any{ 172 | "commandName": "Microsoft_Azure_AD.AddRoleAssignments.batch", 173 | }, 174 | "url": fmt.Sprintf("https://management.azure.com/subscriptions/%s/providers/Microsoft.Authorization/roleAssignments/%s?api-version=2020-04-01-preview", subscriptionID, roleAssignmentID), 175 | }, 176 | }, 177 | } 178 | 179 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 180 | URL: reqURL, 181 | Method: "POST", 182 | Headers: headers, 183 | Body: reqData, 184 | }) 185 | 186 | if respErr != nil { 187 | return respErr 188 | } 189 | 190 | if resp.StatusCode != http.StatusOK { 191 | return fmt.Errorf("cannot add %s to the subscription #%s as Contributor", userAccount.username, subscriptionID) 192 | } 193 | 194 | userAccount.createdRoleAssignments = append(userAccount.createdRoleAssignments, AccountRoleAssignment{ 195 | subscriptionID: subscriptionID, 196 | roleAssignmentID: roleAssignmentID, 197 | }) 198 | 199 | return nil 200 | } 201 | 202 | func (adminAccount *Account) DeleteAccount(userAccount *Account) error { 203 | token, tokenErr := adminAccount.GetAccessToken([]string{"https://graph.microsoft.com/.default"}) 204 | 205 | if tokenErr != nil { 206 | return tokenErr 207 | } 208 | 209 | headers := map[string]any{ 210 | "Authorization": fmt.Sprintf("Bearer %s", token.Token), 211 | "Accept": "*/*", 212 | "Content-Type": "application/json", 213 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 214 | } 215 | 216 | reqURL, reqURLErr := url.Parse("https://graph.microsoft.com/v1.0/$batch") 217 | 218 | if reqURLErr != nil { 219 | return reqURLErr 220 | } 221 | 222 | userAccountID, userAccountIDErr := userAccount.GetID() 223 | 224 | if userAccountIDErr != nil { 225 | return userAccountIDErr 226 | } 227 | 228 | reqData := map[string]any{ 229 | "requests": []map[string]any{ 230 | { 231 | "id": userAccountID, 232 | "method": "DELETE", 233 | "url": fmt.Sprintf("/users/%s", userAccountID), 234 | }, 235 | }, 236 | } 237 | 238 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 239 | URL: reqURL, 240 | Method: "POST", 241 | Headers: headers, 242 | Body: reqData, 243 | }) 244 | 245 | if respErr != nil { 246 | return respErr 247 | } 248 | 249 | if resp.StatusCode != http.StatusOK { 250 | return fmt.Errorf("cannot delete %s user account", userAccount.username) 251 | } 252 | 253 | return nil 254 | } 255 | 256 | func (adminAccount *Account) DeleteCreatedRoleAssignments(userAccount *Account) error { 257 | token, tokenErr := adminAccount.GetAccessToken([]string{"https://management.core.windows.net/.default"}) 258 | 259 | if tokenErr != nil { 260 | return tokenErr 261 | } 262 | 263 | headers := map[string]any{ 264 | "Authorization": fmt.Sprintf("Bearer %s", token.Token), 265 | "Accept": "*/*", 266 | "Content-Type": "application/json", 267 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 268 | } 269 | 270 | reqURL, reqURLErr := url.Parse("https://management.azure.com/batch?api-version=2020-06-01") 271 | 272 | if reqURLErr != nil { 273 | return reqURLErr 274 | } 275 | 276 | allRequests := []map[string]any{} 277 | 278 | for _, roleAssignment := range userAccount.createdRoleAssignments { 279 | allRequests = append(allRequests, map[string]any{ 280 | "httpMethod": "DELETE", 281 | "name": utils.GenerateUUIDv4(), 282 | "requestHeaderDetails": map[string]any{ 283 | "commandName": "Microsoft_Azure_AD.DeleteRoleAssignment.batch", 284 | }, 285 | "url": fmt.Sprintf("https://management.azure.com/subscriptions/%s/providers/Microsoft.Authorization/roleAssignments/%s?api-version=2020-04-01-preview", roleAssignment.subscriptionID, roleAssignment.roleAssignmentID), 286 | }) 287 | } 288 | 289 | reqData := map[string]any{ 290 | "requests": allRequests, 291 | } 292 | 293 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 294 | URL: reqURL, 295 | Method: "POST", 296 | Headers: headers, 297 | Body: reqData, 298 | }) 299 | 300 | if respErr != nil { 301 | return respErr 302 | } 303 | 304 | if resp.StatusCode != http.StatusOK { 305 | return fmt.Errorf("cannot delete %s user account's created role assignments", userAccount.username) 306 | } 307 | 308 | return nil 309 | } 310 | 311 | func (adminAccount *Account) CreateAccount(username, password string, needToBeCleared bool) (Account, error) { 312 | token, tokenErr := adminAccount.GetAccessToken([]string{"https://graph.microsoft.com/.default"}) 313 | 314 | if tokenErr != nil { 315 | return Account{}, tokenErr 316 | } 317 | 318 | adminEmail, adminEmailErr := adminAccount.GetUserPrincipalName() 319 | 320 | if adminEmailErr != nil { 321 | return Account{}, adminEmailErr 322 | } 323 | 324 | if len(strings.Split(adminEmail, "@")) < 2 { 325 | return Account{}, fmt.Errorf("cannot determine Azure domain") 326 | } 327 | 328 | domain := strings.Split(adminEmail, "@")[1] 329 | 330 | headers := map[string]any{ 331 | "Authorization": fmt.Sprintf("Bearer %s", token.Token), 332 | "Accept": "*/*", 333 | "Content-Type": "application/json", 334 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 335 | } 336 | 337 | reqURL, reqURLErr := url.Parse("https://graph.microsoft.com/v1.0/$batch") 338 | 339 | if reqURLErr != nil { 340 | return Account{}, reqURLErr 341 | } 342 | 343 | email := fmt.Sprintf("%s@%s", username, domain) 344 | 345 | reqData := map[string]any{ 346 | "requests": []map[string]any{ 347 | { 348 | "id": utils.GenerateUUIDv4(), 349 | "method": "POST", 350 | "url": "/users", 351 | "body": map[string]any{ 352 | "accountEnabled": true, 353 | "displayName": username, 354 | "passwordProfile": map[string]any{ 355 | "forceChangePasswordNextSignIn": false, 356 | "password": password, 357 | }, 358 | "mailNickname": username, 359 | "userPrincipalName": email, 360 | }, 361 | "headers": map[string]any{ 362 | "Content-Type": "application/json", 363 | }, 364 | }, 365 | }, 366 | } 367 | 368 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 369 | URL: reqURL, 370 | Method: "POST", 371 | Headers: headers, 372 | Body: reqData, 373 | }) 374 | 375 | if respErr != nil { 376 | return Account{}, respErr 377 | } 378 | 379 | var statusCode float64 380 | 381 | bodyResponses, ok := resp.Body["responses"].([]any) 382 | if !ok { 383 | return Account{}, errors.New("can not parse body responses") 384 | } 385 | 386 | bodyResponse, ok := bodyResponses[0].(map[string]any) 387 | if !ok { 388 | return Account{}, errors.New("can not parse body response") 389 | } 390 | 391 | bodyResponseStatusCode, ok := bodyResponse["status"].(float64) 392 | if !ok { 393 | return Account{}, errors.New("can not parse body status code") 394 | } 395 | 396 | statusCode = bodyResponseStatusCode 397 | 398 | if statusCode != http.StatusCreated { 399 | statusMessage := "Unknown error" 400 | 401 | if statusCode > 300 { //nolint:revive,gomnd 402 | responses, ok := resp.Body["responses"].([]any) 403 | if !ok || len(responses) == 0 { 404 | return Account{}, errors.New("unable to cast or empty responses") 405 | } 406 | 407 | response, ok := responses[0].(map[string]any) 408 | if !ok { 409 | return Account{}, errors.New("unable to cast response to map") 410 | } 411 | 412 | body, ok := response["body"].(map[string]any) 413 | if !ok { 414 | return Account{}, errors.New("unable to cast body to map") 415 | } 416 | 417 | errorField, ok := body["error"].(map[string]any) 418 | if !ok { 419 | return Account{}, errors.New("unable to cast error to map") 420 | } 421 | 422 | message, ok := errorField["message"].(string) 423 | if !ok { 424 | return Account{}, errors.New("unable to cast message to string") 425 | } 426 | 427 | statusMessage = message 428 | } 429 | 430 | return Account{}, fmt.Errorf("cannot create %s user account (%s)", username, statusMessage) 431 | } 432 | 433 | responses, ok := resp.Body["responses"].([]any) 434 | if !ok || len(responses) == 0 { 435 | return Account{}, errors.New("unable to cast or empty responses") 436 | } 437 | 438 | response, ok := responses[0].(map[string]any) 439 | if !ok { 440 | return Account{}, errors.New("unable to cast response to map") 441 | } 442 | 443 | body, ok := response["body"].(map[string]any) 444 | if !ok { 445 | return Account{}, errors.New("unable to cast body to map") 446 | } 447 | 448 | accountID, ok := body["id"].(string) 449 | if !ok { 450 | return Account{}, errors.New("unable to cast id to string") 451 | } 452 | 453 | accountUserPrincipalName, ok := body["userPrincipalName"].(string) 454 | if !ok { 455 | return Account{}, errors.New("unable to cast userPrincipalName to string") 456 | } 457 | 458 | return ConnectAccount(email, password, adminAccount.tenantID, needToBeCleared, accountID, accountUserPrincipalName) 459 | } 460 | 461 | func (account *Account) GetAccessToken(scopes []string) (azcore.AccessToken, error) { 462 | token, tokenErr := (*account.tokenCredential).GetToken(context.Background(), policy.TokenRequestOptions{ 463 | Scopes: scopes, 464 | }) 465 | 466 | return token, tokenErr 467 | } 468 | 469 | //nolint:gocritic 470 | func (account *Account) GetCredentials() *azcore.TokenCredential { 471 | return account.tokenCredential 472 | } 473 | 474 | // func (account *Account) createAccount(username, password string) (Account, error) { 475 | 476 | // } 477 | 478 | func getAzIdentityFromNewUsernamePasswordCredential(email, password, tenantID string) (*azidentity.UsernamePasswordCredential, error) { 479 | // Connection from tenantID, clientID, username and password 480 | // Client ID : the client (application) ID of an App Registration in the tenant. 481 | // https://learn.microsoft.com/en-us/javascript/api/@azure/identity/usernamepasswordcredential?view=azure-node-latest 482 | cred, err := azidentity.NewUsernamePasswordCredential(tenantID, "04b07795-8ddb-461a-bbee-02f9e1bf7b46", email, password, nil) 483 | 484 | return cred, err 485 | } 486 | -------------------------------------------------------------------------------- /providers/github/files.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | // Base 64 encoding of .github-files 4 | const SPRAYER_PY_FILE_BASE64 string = "aW1wb3J0IHJlcXVlc3RzCmltcG9ydCBvcwpmcm9tIHVybGxpYjMuZXhjZXB0aW9ucyBpbXBvcnQgSW5zZWN1cmVSZXF1ZXN0V2FybmluZwpmcm9tIENyeXB0by5DaXBoZXIgaW1wb3J0IEFFUwpmcm9tIENyeXB0byBpbXBvcnQgUmFuZG9tCmltcG9ydCB0eXBpbmcKaW1wb3J0IGJhc2U2NAoKQ0hVTktfU0laRSA9IDUwMDAKCnJlcXVpcmVkRW52VmFycyA9IFsiTUVUSE9EX0hFWCIsICJVUkxfSEVYIiwgIkhFQURFUlNfSEVYIiwgIkJPRFlfSEVYIiwgIkFFUzI1Nl9LRVlfSEVYIl0KbWlzc2luZ0VudlZhcnMgPSBbdmFyIGZvciB2YXIgaW4gcmVxdWlyZWRFbnZWYXJzIGlmIG9zLmdldGVudih2YXIpIGlzIE5vbmVdCgppZiBtaXNzaW5nRW52VmFyczoKICAgIG1pc3NpbmdWYXJzU3RyID0gIiwgIi5qb2luKG1pc3NpbmdFbnZWYXJzKQogICAgCiAgICByYWlzZSBWYWx1ZUVycm9yKGYiTWlzc2luZyBlbnZpcm9ubWVudCB2YXJpYWJsZXM6IHttaXNzaW5nVmFyc1N0cn0iKQoKZW52TWV0aG9kID0gYnl0ZXMuZnJvbWhleChvcy5nZXRlbnYoIk1FVEhPRF9IRVgiKSkKZW52VVJMID0gYnl0ZXMuZnJvbWhleChvcy5nZXRlbnYoIlVSTF9IRVgiKSkKZW52SGVhZGVycyA9IGJ5dGVzLmZyb21oZXgob3MuZ2V0ZW52KCJIRUFERVJTX0hFWCIsIGRlZmF1bHQ9IiIpKQplbnZCb2R5ID0gYnl0ZXMuZnJvbWhleChvcy5nZXRlbnYoIkJPRFlfSEVYIiwgZGVmYXVsdD0iIikpCmFlc0tleSA9IGJ5dGVzLmZyb21oZXgob3MuZ2V0ZW52KCJBRVMyNTZfS0VZX0hFWCIsIGRlZmF1bHQ9IiIpKQoKZGVmIGFlczI1Nl9lbmNyeXB0KGRhdGE6IGJ5dGVzLCBrZXk6IGJ5dGVzKSAtPiBzdHI6CiAgICAiIiIKICAgICAgICAgRW5jcnlwdCB1c2luZyBBRVMtMjU2LUdDTSByYW5kb20gaXYKICAgICAgICAna2V5JyBtdXN0IGJlIGJ5dGVzIGZyb20gaGV4LCBnZW5lcmF0ZSB3aXRoICdvcGVuc3NsIHJhbmQgLWhleCAzMicKICAgICAgICAgUmV0dXJucyBhIGhleCBzdHIKICAgICIiIgogICAgdHJ5OgogICAgICAgIGl2ID0gUmFuZG9tLmdldF9yYW5kb21fYnl0ZXMoMTIpICMgUmVjb21tZW5kZWQgbm9uY2Ugc2l6ZSBmb3IgQUVTIEdDTSA6IDEyIGJ5dGVzCgogICAgICAgIGNpcGhlciA9IEFFUy5uZXcoa2V5LCBBRVMuTU9ERV9HQ00sIGl2KQoKICAgICAgICBjaXBoZXJfZGF0YSA9IGNpcGhlci5lbmNyeXB0KGRhdGEpCiAgICAgICAgdGFnID0gY2lwaGVyLmRpZ2VzdCgpCgogICAgICAgIHJlc3VsdCA9IGl2LmhleCgpK2NpcGhlcl9kYXRhLmhleCgpK3RhZy5oZXgoKSAjIFJlc3VsdCA6IElWICsgQ0lQSEVSIERBVEEgKyBUQUcgKFRhZyBpcyB1c2VkIGJ5IEdDTSBmb3IgYXV0aGVudGljYXRpb24gcHVycG9zZXMpCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcHJpbnQoIkNhbm5vdCBlbmNyeXB0IGRhdGFzLi4uIikKICAgICAgICBwcmludChlKQogICAgICAgIGV4aXQoMSkKICAgIHJldHVybiByZXN1bHQKCmRlZiBhZXMyNTZfZGVjcnlwdChlbmNyeXB0ZWREYXRhOiBieXRlcywga2V5OiBieXRlcykgLT4gYnl0ZXM6CiAgICAiIiIKICAgICAgICAgRGVjcnlwdCB1c2luZyBBRVMtMjU2LUdDTSByYW5kb20gaXYKICAgICAgICAna2V5SGV4U3RyJyBtdXN0IGJlIGluIGhleCwgZ2VuZXJhdGUgd2l0aCAnb3BlbnNzbCByYW5kIC1oZXggMzInCiAgICAiIiIKICAgIHRyeToKICAgICAgICBpdiA9IGVuY3J5cHRlZERhdGFbOjEyXQogICAgICAgIGVuY3J5cHRlZF9kYXRhID0gZW5jcnlwdGVkRGF0YVsxMjotMTZdCiAgICAgICAgdGFnID0gZW5jcnlwdGVkRGF0YVstMTY6XQoKICAgICAgICBjaXBoZXIgPSBBRVMubmV3KGtleSwgQUVTLk1PREVfR0NNLCBpdikKICAgICAgICBkZWNyeXB0ZWQgPSBjaXBoZXIuZGVjcnlwdF9hbmRfdmVyaWZ5KGVuY3J5cHRlZF9kYXRhLCB0YWcpCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcHJpbnQoIkNhbm5vdCBkZWNyeXB0IGRhdGEuLi4iKQogICAgICAgIHByaW50KGUpCiAgICAgICAgZXhpdCgxKQogICAgcmV0dXJuIGRlY3J5cHRlZAoKZGVmIHBhcnNlSW5wdXRzKGVudk1ldGhvZDogYnl0ZXMsIGVudlVSTDogYnl0ZXMsIGVudkhlYWRlcnM6IGJ5dGVzLCBlbnZCb2R5OiBieXRlcywgYWVzS2V5OiBieXRlcykgLT4gdHlwaW5nLlR1cGxlW2J5dGVzLCBieXRlcywgZGljdCwgYnl0ZXNdOgogICAgbWV0aG9kID0gYWVzMjU2X2RlY3J5cHQoZW52TWV0aG9kLCBhZXNLZXkpCiAgICB1cmwgPSBhZXMyNTZfZGVjcnlwdChlbnZVUkwsIGFlc0tleSkKICAgIAogICAgaGVhZGVycyA9IHt9CiAgICBpZiBsZW4oZW52SGVhZGVycykgPiAwOgogICAgICAgIGhlYWRlcnNSYXcgPSBhZXMyNTZfZGVjcnlwdChlbnZIZWFkZXJzLCBhZXNLZXkpCiAgICAgICAgaGVhZGVyc0xpbmVzID0gaGVhZGVyc1Jhdy5zcGxpdGxpbmVzKCkKICAgICAgICBpZiBsZW4oaGVhZGVyc0xpbmVzKSUyICE9IDA6CiAgICAgICAgICAgIHJhaXNlIEV4Y2VwdGlvbigiQ2FuIG5vdCBwYXJzZSB0aGUgcmVxdWVzdCBoZWFkZXJzLiIpCgogICAgICAgIGZvciBpIGluIHJhbmdlKDAsIGxlbihoZWFkZXJzTGluZXMpLCAyKToKICAgICAgICAgICAgaGVhZGVyc1toZWFkZXJzTGluZXNbaV1dID0gaGVhZGVyc0xpbmVzW2krMV0KCiAgICBib2R5ID0gTm9uZQogICAgaWYgbGVuKGVudkJvZHkpID4gMDoKICAgICAgICBib2R5ID0gYWVzMjU2X2RlY3J5cHQoZW52Qm9keSwgYWVzS2V5KQoKICAgIHJldHVybiBtZXRob2QsIHVybCwgaGVhZGVycywgYm9keQoKZGVmIGVuY3J5cHRPdXRwdXRzKHN0YXR1czogaW50LCBoZWFkZXJzOiBkaWN0LCBib2R5OiBieXRlcywgYWVzS2V5OiBieXRlcykgLT4gdHlwaW5nLlR1cGxlW3N0ciwgc3RyLCBzdHJdOgogICAgc3RhdHVzSGV4ID0gIiIKICAgIGlmIHN0YXR1cyAhPSBOb25lOgogICAgICAgIHN0YXR1c0hleCA9IGFlczI1Nl9lbmNyeXB0KHN0cihzdGF0dXMpLmVuY29kZSgpLCBhZXNLZXkpCgogICAgaGVhZGVyc1N0ciA9ICIiCiAgICBpZiBoZWFkZXJzICE9IE5vbmU6CiAgICAgICAgZm9yIGtleSBpbiBoZWFkZXJzOgogICAgICAgICAgICBoZWFkZXJzU3RyICs9IGtleSArICJcbiIgKyBoZWFkZXJzW2tleV0gKyAiXG4iCiAgICAgICAgaWYgbGVuKGhlYWRlcnNTdHIpID4gMDoKICAgICAgICAgICAgaGVhZGVyc1N0ciA9IGhlYWRlcnNTdHJbOi0xXQogICAgaGVhZGVyc0hleCA9IGFlczI1Nl9lbmNyeXB0KGhlYWRlcnNTdHIuZW5jb2RlKCksIGFlc0tleSkKCiAgICBib2R5SGV4ID0gIiIKICAgIGlmIGJvZHkgIT0gTm9uZToKICAgICAgICBib2R5SGV4ID0gYWVzMjU2X2VuY3J5cHQoYm9keSwgYWVzS2V5KQoKICAgIHJldHVybiBzdGF0dXNIZXgsIGhlYWRlcnNIZXgsIGJvZHlIZXgKCmRlZiBtYWtlUmVxdWVzdChtZXRob2Q6IGJ5dGVzLCB1cmw6IGJ5dGVzLCBoZWFkZXJzOmRpY3Q9Tm9uZSwgYm9keTpieXRlcz1Ob25lKSAtPiB0eXBpbmcuVHVwbGVbaW50LCBkaWN0LCBieXRlcywgc3RyXToKICAgIHRyeToKICAgICAgICByZXNwb25zZSA9IHJlcXVlc3RzLnJlcXVlc3QobWV0aG9kLCB1cmwsIGhlYWRlcnM9aGVhZGVycywgZGF0YT1ib2R5LCB2ZXJpZnk9RmFsc2UsIGFsbG93X3JlZGlyZWN0cz1GYWxzZSwgc3RyZWFtPVRydWUpCiAgICAKICAgICAgICBzdGF0dXMgPSByZXNwb25zZS5zdGF0dXNfY29kZQogICAgICAgIGhlYWRlcnMgPSByZXNwb25zZS5oZWFkZXJzCiAgICAgICAgYm9keSA9IHJlc3BvbnNlLnJhdy5yZWFkKCkKCiAgICAgICAgcmV0dXJuIHN0YXR1cywgaGVhZGVycywgYm9keSwgTm9uZQogICAgZXhjZXB0IHJlcXVlc3RzLlJlcXVlc3RFeGNlcHRpb24gYXMgZToKICAgICAgICByZXR1cm4gTm9uZSwgTm9uZSwgTm9uZSwgYmFzZTY0LmI2NGVuY29kZShzdHIoZSkuZW5jb2RlKCkpLmRlY29kZSgpCgpyZXFNZXRob2QsIHJlcVVSTCwgcmVxSGVhZGVycywgcmVxQm9keSA9IHBhcnNlSW5wdXRzKGVudk1ldGhvZCwgZW52VVJMLCBlbnZIZWFkZXJzLCBlbnZCb2R5LCBhZXNLZXkpCgpyZXNwU3RhdHVzLCByZXNwSGVhZGVycywgcmVzcEJvZHksIHJlc3BFcnIgPSBtYWtlUmVxdWVzdChyZXFNZXRob2QsIHJlcVVSTCwgcmVxSGVhZGVycywgcmVxQm9keSkKCmlmIHJlc3BFcnIgIT0gTm9uZToKICAgIHByaW50KCJSRVNQX0VSUiIsIHJlc3BFcnIpCmVsc2U6CiAgICByZXNwU3RhdHVzSGV4LCByZXNwSGVhZGVyc0hleCwgcmVzcEJvZHlIZXggPSBlbmNyeXB0T3V0cHV0cyhyZXNwU3RhdHVzLCByZXNwSGVhZGVycywgcmVzcEJvZHksIGFlc0tleSkKCiAgICBwcmludCgiUkVTUF9TVEFUVVNfRU5DUllQVEVEX0hFWCIsIHJlc3BTdGF0dXNIZXgpCiAgICAKICAgICMgUHJpbnRzIGFyZSBjaHVua2VkIGluIG9yZGVyIHRvIGF2b2lkIEdpdEh1YiBpc3N1ZXMgYW5kIHNsb3duZXNzIG9mIHByaW50aW5nIGxhcmdlIHN0cmluZ3MKICAgIGZvciBpIGluIHJhbmdlKDAsIGxlbihyZXNwSGVhZGVyc0hleCksIENIVU5LX1NJWkUpOgogICAgICAgIHByaW50KCJSRVNQX0hFQURFUlNfRU5DUllQVEVEX0hFWCIsIHJlc3BIZWFkZXJzSGV4W2k6aSArIENIVU5LX1NJWkVdKQogICAgCiAgICAjIFByaW50cyBhcmUgY2h1bmtlZCBpbiBvcmRlciB0byBhdm9pZCBHaXRIdWIgaXNzdWVzIGFuZCBzbG93bmVzcyBvZiBwcmludGluZyBsYXJnZSBzdHJpbmdzCiAgICBmb3IgaSBpbiByYW5nZSgwLCBsZW4ocmVzcEJvZHlIZXgpLCBDSFVOS19TSVpFKToKICAgICAgICBwcmludCgiUkVTUF9CT0RZX0VOQ1JZUFRFRF9IRVgiLCByZXNwQm9keUhleFtpOmkgKyBDSFVOS19TSVpFXSk=" //nolint:revive,stylecheck 5 | const REQUIREMENTS_TXT_FILE_BASE64 string = "Y2VydGlmaQpjaGFyc2V0LW5vcm1hbGl6ZXIKaWRuYQpyZXF1ZXN0cwp1cmxsaWIzCnB5Y3J5cHRvZG9tZQ==" //nolint:revive,stylecheck 6 | const SPRAYER_YML_FILE_BASE64 string = "bmFtZTogU3ByYXllcgoKb246CiAgd29ya2Zsb3dfZGlzcGF0Y2g6CiAgICBpbnB1dHM6CiAgICAgIG1ldGhvZEVuYzoKICAgICAgICBkZXNjcmlwdGlvbjogJ1JlcXVlc3QgbWV0aG9kIGVuY29kZWQgYXMgQmFzZTY0JwogICAgICAgIHJlcXVpcmVkOiB0cnVlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgIHVybEVuYzoKICAgICAgICBkZXNjcmlwdGlvbjogJ1JlcXVlc3QgVVJMIGVuY29kZWQgYXMgQmFzZTY0JwogICAgICAgIHJlcXVpcmVkOiB0cnVlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgIGhlYWRlcnNFbmM6CiAgICAgICAgZGVzY3JpcHRpb246ICdSZXF1ZXN0IGhlYWRlcnMgZW5jb2RlZCBhcyBCYXNlNjQnCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgIGJvZHlFbmM6CiAgICAgICAgZGVzY3JpcHRpb246ICdSZXF1ZXN0IGJvZHkgZW5jb2RlZCBhcyBCYXNlNjQnCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgIHJ1bklkZW50aWZpZXI6CiAgICAgICAgZGVzY3JpcHRpb246ICdydW4gaWRlbnRpZmllcicKICAgICAgICByZXF1aXJlZDogZmFsc2UKCmpvYnM6CiAgaWQ6CiAgICBuYW1lOiBXb3JrZmxvdyBJRCBQcm92aWRlcgogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgc3RlcHM6CiAgICAgIC0gbmFtZTogJHt7Z2l0aHViLmV2ZW50LmlucHV0cy5ydW5JZGVudGlmaWVyfX0KICAgICAgICBydW46IGVjaG8gcnVuIGlkZW50aWZpZXIgJHt7IGlucHV0cy5ydW5JZGVudGlmaWVyIH19CiAgcmVxOgogICAgbmFtZTogUmVxdWVzdAogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgc3RlcHM6CiAgICAgIC0gdXNlczogYWN0aW9ucy9jaGVja291dEB2MgogICAgICAtIG5hbWU6IFNldCB1cCBQeXRob24KICAgICAgICB1c2VzOiBhY3Rpb25zL3NldHVwLXB5dGhvbkB2MgogICAgICAgIHdpdGg6CiAgICAgICAgICBweXRob24tdmVyc2lvbjogJzMueCcKICAgICAgLSBuYW1lOiBJbnN0YWxsIGRlcGVuZGVuY2llcwogICAgICAgIHJ1bjogcHl0aG9uMyAtbSBwaXAgaW5zdGFsbCAtciByZXF1aXJlbWVudHMudHh0CiAgICAgIC0gbmFtZTogUnVuIHNjcmlwdAogICAgICAgIHJ1bjogcHl0aG9uIHNwcmF5ZXIucHkKICAgICAgICBlbnY6CiAgICAgICAgICBNRVRIT0RfSEVYOiAke3sgaW5wdXRzLm1ldGhvZEVuYyB9fQogICAgICAgICAgVVJMX0hFWDogJHt7IGlucHV0cy51cmxFbmMgfX0KICAgICAgICAgIEhFQURFUlNfSEVYOiAke3sgaW5wdXRzLmhlYWRlcnNFbmMgfX0gCiAgICAgICAgICBCT0RZX0hFWDogJHt7IGlucHV0cy5ib2R5RW5jIH19ICAKICAgICAgICAgIEFFUzI1Nl9LRVlfSEVYOiAke3sgc2VjcmV0cy5BRVMyNTZfS0VZX0hFWCB9fQogICAgICA=" //nolint:revive,stylecheck 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔁 IPSpinner 2 | 3 | IPSpinner is a local proxy which can be used to redirect all incoming requests through different chosen providers. The purpose is to create a pass-through proxy that rotates the source IP address of each request. As an example, running a bruteforce operation through IPSpinner will help to avoid being caught because the server will receive the requests from hundreds of different IP addresses. 4 | 5 | IPSpinner currently supports AWS (API Gateway), Azure (Cloud Shell) and GitHub (GitHub Actions). 6 | 7 | # Table of contents 8 | 9 | 1. [How it works?](#i-how-it-works) 10 | 1. [General](#1-general) 11 | 2. [Per provider & launcher](#2-per-provider-launcher) 12 | 1. [AWS API Gateway](#i-aws-api-gateway) 13 | 2. [Azure Cloud Shell](#ii-azure-cloud-shell) 14 | 3. [GitHub Actions](#iii-github-actions) 15 | 3. [Launcher comparison](#3-launcher-comparison) 16 | 2. [How to install?](##ii-how-to-install) 17 | 1. [Install Go](#1-install-go) 18 | 2. [Clone and build IPSpinner](#2-clone-and-build-ipspinner) 19 | 3. [Clean builds](#3-clean-builds) 20 | 3. [How to use?](#iii-how-to-use) 21 | 1. [General](#1-general-1) 22 | 1. [Command line arguments](#i-command-line-arguments) 23 | 2. [Configuration file](#ii-configuration-file) 24 | 2. [Per provider](#2-per-provider) 25 | 1. [AWS](#i-aws) 26 | 2. [Azure](#ii-azure) 27 | 3. [GitHub](#iii-github) 28 | 4. [How to ...?](#iv-how-to-) 29 | 1. [HTTP/2 support?](#1-http2-support) 30 | 31 | # I/ How it works? 32 | 33 | ## 1) General 34 | 35 |
36 | Figure 1: IPSpinner - Overall diagram 37 | Figure 1: IPSpinner - Overall diagram 38 |
39 | 40 | IPSpinner works as a local proxy that redirects requests through external services. For this purpose, IPSpinner leverages providers and launchers. 41 | 42 | A provider corresponds to a cloud provider or an online service provider (AWS, Azure, GitHub, etc.), which offers different services, so called launchers, that can be used to relay user's requests (AWS API Gateway, GitHub Actions, Azure Cloud Shells, etc.). 43 | 44 | Thus, in order to launch IPSpinner, the user will have to provide credentials for the providers he wants to use and additional configurations for the launchers. Multiple launcher types can be used at the same time, IPSpinner will choose randomly one of the available ones for each request. 45 | 46 | Moreover, IPSpinner implements a preload feature. Some launchers can be preloaded to avoid the reconfiguration delay in the case a new host is seen by the proxy. For these launcher, the preload procedure is recommended but not mandatory. For the others, no preloading is necessary. 47 | 48 | 49 | 50 | ## 2) Per provider & launcher 51 | 52 | ### i. AWS API Gateway 53 | 54 | #### Introduction 55 | 56 | IPSpinner can leverage AWS API Gateway for sending requests. This implementation is based on [FireProx](https://github.com/ustayready/fireprox), which creates a REST API Gateway to redirect incoming requests. FireProx has therefore been adapted to handle multiple hosts per API Gateway and to implement new features. To sum up, when IPSpinner receives a request, it selects or creates the right API Gateway instance and sends towards the request. Then, it gathers the response and returns it to the user. Thus, the targeted server has received the request from the API Gateway and not directly from the user. As API Gateway rotates its outgoing IP for each request, IPSpinner uses this feature to make IP address rotating. 57 | 58 |
59 | Figure 2: AWS API Gateway - Overall diagram 60 | Figure 2: AWS API Gateway - Overall diagram 61 |
62 | 63 | The next graph, made in October 2024, shows the number of unique IP addresses available per AWS region according to the number of requests sent. Most of the regions offer more than 100 IP addresses and multiple regions can be used at the same time, allowing the user to proxifies his requests through thousands of worldwide addresses. 64 | 65 |
66 | Figure 3: AWS API Gateway - Available IP addresses per region 67 | Figure 3: AWS API Gateway - Available IP addresses per region 68 |
69 | 70 | Finally, the Figure 4 shows, with a logarithmic green color level, how many addresses are available per country. It demonstrates that the user has the possibility to falsify his source IP address with addresses on any continent. 71 | 72 |
73 | Figure 4: AWS API Gateway - IP addresses per country 74 | Figure 4: AWS API Gateway - IP addresses per country 75 |
76 | 77 | #### Noteworthy details 78 | 79 | IPSpinner implements a rotation feature that reguarly deletes and renews created FireProx instances. As the following graph shows, rotating a FireProx instance may deliver a new subset of IP. However, each AWS region has a limited set of IP and therefore at some point, rotations will not deliver new IPs. 80 | 81 |
82 | Figure 5: AWS API Gateway - Rotating process 83 | Figure 5: AWS API Gateway - Rotating process 84 |
85 | 86 | This launcher implements a preloading procedure. As said before, it is not mandatory, but can prevent some reconfiguration delays or synchronisation errors during the first seconds after being reconfigured. 87 | 88 | Moreover, API Gateways set by default a X-Forwarded-For header, which cannot be deleted but can be overrided. Thus, the user can specify in the IPSpinner configuration an IP address range from which an random IP will be chosen for each request (IPv4 or IPv6 range). 89 | 90 | 91 | ### ii. Azure Cloud Shell 92 | 93 | #### Introduction 94 | 95 | IPSpinner leverages Azure Cloud Shell to send requests. An Azure Cloud Shell is an interactive, authenticated, browser-accessible terminal for managing Azure resources. Cloud Shell runs on a temporary host provided on a per-session, per-user basis. 96 | 97 | Thus, IPSpinner uses several Azure users for whom a Cloud Shell session is prepared. Then, each request will be redirected to an initialised Cloud Shell, before being renewed to reset its IP address. 98 | 99 |
100 | Figure 6: Azure Cloud Shell - Overall diagram 101 | Figure 6: Azure Cloud Shell - Overall diagram 102 |
103 | 104 | As the following graph shows, the different regions available to deploy Cloud Shells sessions each offer dozens of IP addresses. The user can configure multiple regions at the same time to increase his IP pool. 105 | 106 |
107 | Figure 7: Azure Cloud Shell - Available IP addresses per region 108 | Figure 7: Azure Cloud Shell - Available IP addresses per region 109 |
110 | 111 | However, IP addresses are more concentrated than for AWS API Gateway. As the following map illustrates, most of them are located in the US, in Europe and in India. 112 | 113 |
114 | Figure 8: Azure Cloud Shell - IP addresses per country 115 | Figure 8: Azure Cloud Shell - IP addresses per country 116 |
117 | 118 | #### Noteworthy details 119 | 120 | Due to the Cloud Shell renewing process delay, we advise to limit the request flow rate. More information in the [launcher comparison](#3-launcher-comparison) subsection. 121 | 122 | 123 | 124 | 125 | ### iii. GitHub Actions 126 | 127 | #### Introduction 128 | 129 | IPSpinner can also take advantage of the GitHub Actions to send requests. This implementation is inspired of [git-rotate](https://github.com/dunderhay/git-rotate) but has been completely modified and adapted to get rid of the catcher server. 130 | 131 | It creates a repository with a predefined workflow template. Then, for each request, it runs the workflow by giving request information through the environment variables. All data is encrypted to avoid being readable by an external user. IPSpinner finally collects response data from the workflow logs. 132 | 133 |
134 | Figure 9: GitHub Actions - Overall diagram 135 | Figure 9: GitHub Actions - Overall diagram 136 |
137 | 138 | The following figure shows that GitHub Actions offer thousands of different IP addresses. 139 | 140 |
141 | Figure 10: GitHub Actions - Available IP addresses per region 142 | Figure 10: GitHub Actions - Available IP addresses per region 143 |
144 | 145 | 146 | However, the following map illustrates that GitHub Actions only offer American IP addresses. After analysis, their worker seem to be deployed on an Azure infrastructure. 147 | 148 |
149 | Figure 11: GitHub Actions - IP addresses per country 150 | Figure 11: GitHub Actions - IP addresses per country 151 |
152 | 153 | #### Noteworthy details 154 | 155 | ⚠️ Moreover, *"GitHub takes abuse and spam of Actions seriously, and they have a dedicated team to track “spammy users.”"*. Thus, the user MUST NOT use this provider with its own account or with the company account to avoid any account closure issue. 156 | 157 | Due to the per-hour GitHub REST API limit, the maximum request flow rate must be limited to avoid any disruption. More information in the [launcher comparison](#3-launcher-comparison) subsection. 158 | 159 | 160 | ## 3) Launcher comparison 161 | 162 | | | AWS API Gateway | Azure Cloud Shell | GitHub Actions | 163 | |---------------------------|-----------------|-------------------|----------------| 164 | | Available IP addresses | ≈ 12,418 | ≈ 276 | > 6,000 | 165 | | Mean response time | 0.46s | 13.04s | 21.42s | 166 | | Mean reconfiguration time | None | 20s | None | 167 | | Maximal theoretical flow rate | 4,000 to 16,000 req/h | 107 req/h/cloud shell instance | 1,000 req/h | 168 | | Can/Need to be preloaded? | ✅ | ❌ | ❌ | 169 | | | | | | 170 | | Use for: browsing | ✅ | ❌ | ❌ | 171 | | Use for: password spraying | ✅ | ✅ | ✅ | 172 | 173 | # II/ How to install? 174 | 175 | ## 1) Install Go 176 | 177 | This project has been tested for a go version >= 1.21 but may work with lower go version. 178 | 179 | See [Go installation documentation](https://go.dev/doc/install) 180 | 181 | After installation, make sure the default go binary is the correct one: 182 | 183 | ```bash 184 | $ go version 185 | go version go1.21.1 linux/amd64 186 | ``` 187 | 188 | ## 2) Clone and build IPSpinner 189 | 190 | ```bash 191 | $ git clone https://github.com/synacktiv/IPSpinner.git 192 | $ cd IPSpinner 193 | $ go mod tidy 194 | 195 | $ make build-linux # For Linux AMD64 arch 196 | $ make build-windows # For Windows AMD64 arch 197 | ``` 198 | 199 | The executable will be named by default "ipspinner" on Linux or "ipspinner.exe" on Windows. 200 | 201 | ## 3) Clean builds 202 | 203 | At the end of use, you can clean builds by running 204 | ```bash 205 | $ make clean 206 | ``` 207 | 208 | # III/ How to use? 209 | 210 | ## 1) General 211 | 212 | For getting help using IPSpinner, you can run the command without any argument : 213 | 214 | ```bash 215 | $ ./ipspinner -h 216 | Help will be displayed 217 | ``` 218 | 219 | All information (excluding the request redirections) are logged into the ipspinner.log file. 220 | 221 | Some common options are available as arguments and the other configuration information have to be provided into an INI configuration file. 222 | 223 | ### i. Command line arguments 224 | 225 | The user can specify some command line arguments: 226 | | Parameter | Mandatory | Default value | 227 | | :---------------- | :------: | :------: | 228 | | --config | ❌ | config.ini | 229 | | --export-ca-cert | ❌ | | 230 | | --host | ❌ | | 231 | | --port | ❌ | 8080 | 232 | | --v, --vv, --vvv | ❌ | | 233 | 234 | Some global and providers parameters have to be specified into an INI configuration file. The configuration file must be prepared before running IPSpinner. Its content will be explained in the next subsections. By default, IPSpinner looks for a configuration file named [*config.ini*](config.ini). 235 | 236 | For handling https requests, IPSpinner needs a Certificate Authority (CA) certificate and a key. If the user does not provide a certificate, IPSpinner will generate its own self-signed certificate and key. The user can ask for retrieving the generated certificate with **--export-ca-cert** (ex: for importing it into the browser). Otherwise, the user can provide its own CA certificate and key in the configuration file (see next parts). 237 | 238 | The user can specify the listening host and port with **--host** and **--port**. 239 | 240 | Finally, three verbose modes are available: 241 | - **--v**: prints creation logs 242 | - **--vv**: same as **--v** and prints request redirections 243 | - **--vvv**: same as **--vv** and prints request detailed informations 244 | 245 | ### ii. Configuration file 246 | 247 | Then, a template for the [INI config file](config.ini) is available in the project repository. 248 | 249 | In the **proxy** section, the user can specify some parameters: 250 | | Parameter | Mandatory | Default value | Description | 251 | | :---------------- | :------: | :------: | :---- | 252 | | preload_hosts_file | ❌ | | a list of URLs/hosts to preload, for the providers which can preload hosts | 253 | | whitelist_hosts_file | ❌ | | a list of URLs/hosts which are whitelisted (all others will be blacklisted by default) | 254 | | blacklist_hosts_file | ❌ | | a list of URLs/hosts which are blacklisted (ignored if the whitelist is set) | 255 | | ca_cert_file & ca_cert_key_file | ❌ | | a user-provided CA certificate (if the user wants to replace the default generated one) | 256 | | user_agents_file | ❌ | | a list of user agents that will be randomly chosen for requests | 257 | | debug_response_headers | ❌ | false | adds two debug headers in the proxy responses: X-IPSpinner-Provider and X-IPSpinner-Provider-NbTotalReqSent | 258 | | wait_for_launcher_available_timeout | ❌ | 60 | number of seconds before timing out a request if no launcher becomes available | 259 | 260 | All other sections will be described in the corresponding provider chapter. 261 | 262 | It is important to notice that a user can enable multiple providers and launchers at the same time. IPSpinner will then choose a random launcher among all available ones for each request. 263 | 264 | 265 | ## 2) Per provider 266 | 267 | ### i. AWS 268 | 269 | Configuration parameters for AWS, in the **aws** section: 270 | | Parameter | Mandatory | Default value | Description | 271 | | :---------------- | :------: | :------: | :---- | 272 | | regions | ✅ | | List of regions, separated by a comma, where ressources can be deployed | 273 | | profile | ❌ | | AWS CLI profile to use | 274 | | access_key | ✅
(or profile) | | AWS user access key | 275 | | secret_key | ✅
(or profile) | | AWS user secret key | 276 | | session_token | ❌ | | AWS user session token | 277 | 278 |
279 | 280 | Configuration parameters for the API Gateways, in the **aws** section: 281 | | Parameter | Mandatory
(if ag_enabled=true) | Default value | Description | 282 | | :---------------- | :------: | :------: | :---- | 283 | | ag_enabled | / | | Enable the API Gateway launcher | 284 | | ag_max_instances | ❌ | 5 | Max API Gateway instances that can be deployed (overall maximum, not per region) | 285 | | ag_rotate_nb_requests | ❌ | 5,000 | Number of requests before rotating an API Gateway | 286 | | ag_forwarded_for_range | ❌ | 35.180.0.0/16 | IP address range for the X-Forwarded-For header (IPv4 or IPv6 range) | 287 | | ag_instance_title_prefix | ❌ | fpr | API Gateway information customisation | 288 | | ag_instance_deployment_description | ❌ | IPSpinner FireProx Prod | API Gateway information customisation | 289 | | ag_instance_deployment_stage_description | ❌ | IPSpinner FireProx Prod Stage | API Gateway information customisation | 290 | | ag_instance_deployment_stage_name | ❌ | *3 random english words* | API Gateway information customisation | 291 | 292 | ### ii. Azure 293 | 294 | Configuration parameters for Azure, in the **azure** section: 295 | | Parameter | Mandatory | Default value | Description | 296 | | :---------------- | :------: | :------: | :---- | 297 | | admin_email | ✅
(or accounts_file) | | Azure administrator email | 298 | | admin_password | ✅
(or accounts_file) | | Azure administrator password | 299 | | tenant_id | ✅ | | Tenant ID | 300 | | subscription_id | ✅ | | Subscription ID | 301 | | accounts_file | ❌ | | A list of pre-created accounts (email and password, one info per line) that override admin_email and admin_password | 302 | 303 |
304 | 305 | Configuration parameters for Azure Cloud Shell, in the **azure** section: 306 | | Parameter | Mandatory
(if cs_enabled=true) | Default value | Description | 307 | | :---------------- | :------: | :------: | :---- | 308 | | cs_enabled | / | | Enable the Cloud Shell launcher | 309 | | cs_preferred_locations | ✅ | | Locations for deploying Cloud Shell instances | 310 | | cs_nb_instances | ❌ | 5 | Number of Cloud Shell instances to deploy | 311 | 312 | ### iii. GitHub 313 | 314 | Configuration parameters for GitHub, in the **github** section: 315 | | Parameter | Mandatory | Default value | Description | 316 | | :---------------- | :------: | :------: | :---- | 317 | | username | ✅ | | GitHub username | 318 | | token | ✅ | | GitHub token associated to the provided username | 319 | 320 |
321 | 322 | Configuration parameters for GitHub Actions, in the **github** section: 323 | | Parameter | Mandatory
(if ga_enabled=true) | Default value | Description | 324 | | :---------------- | :------: | :------: | :---- | 325 | | ga_enabled | / | | Enable the GitHub Actions launcher | 326 | 327 | # IV/ How to ...? 328 | 329 | ## 1) HTTP/2 support? 330 | 331 | IPSpinner does not support the HTTP/2 protocol. Since the proxy terminates the first TLS connection, the protocol advantages are lost and appear as a basic HTTP/1.1 connection. 332 | 333 | Thus, in order to avoid HTTP/2 issues when using IPSpinner with Burp Suite, please remove HTTP/2 client support: Settings > Network > HTTP > HTTP/2 > Untick the HTTP/2 checkbox. -------------------------------------------------------------------------------- /providers/azure/cloudshell.go: -------------------------------------------------------------------------------- 1 | package azure 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "ipspinner/utils" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/gorilla/websocket" 17 | ) 18 | 19 | // Launcher object 20 | type CloudShell struct { 21 | provider *Azure 22 | account *Account 23 | socket *websocket.Conn 24 | name string 25 | preferredLocation string 26 | socketCreatedTime int64 27 | nbTotalRequestsSent int 28 | socketClosed bool 29 | isAvailable bool // can send new requests (socket has been renewed) 30 | stopped bool 31 | } 32 | 33 | const maxCloudShellLifeTime = 10 * 60 34 | 35 | func (instance CloudShell) SummarizeState() string { 36 | return fmt.Sprintf("Launcher %s : nbTotalRequestsSent=%d, socketCreatedTime=%d, socketClosed=%t, isAvailable=%t", instance.GetName(), instance.GetNbTotalReqSent(), instance.socketCreatedTime, instance.socketClosed, instance.isAvailable) 37 | } 38 | 39 | func CreateCloudShellAccount(provider *Azure, subscriptionID string) (Account, error) { 40 | username := "ips.cs." + utils.GenerateRandomPrefix(10) //nolint:revive,gomnd 41 | password := utils.GenerateRandomPassword(15) //nolint:revive,gomnd 42 | 43 | account, accountErr := provider.GetAdminAccount().CreateAccount(username, password, true) 44 | 45 | if accountErr != nil { 46 | return Account{}, accountErr 47 | } 48 | 49 | addToSubErr := provider.GetAdminAccount().AddAccountAsContributorToSubscription(&account, subscriptionID) 50 | 51 | if addToSubErr != nil { 52 | return Account{}, addToSubErr 53 | } 54 | 55 | return account, nil 56 | } 57 | 58 | func CreateCloudShell(provider *Azure, subscriptionID, preferredLocation string, account Account) (*CloudShell, error) { 59 | utils.Logger.Info().Str("launcher", account.username).Msg("Creating launcher.") 60 | 61 | azureWebSocket := CloudShell{ 62 | name: account.username, 63 | preferredLocation: preferredLocation, 64 | nbTotalRequestsSent: 0, 65 | provider: provider, 66 | account: &account, 67 | socketClosed: true, 68 | isAvailable: true, 69 | } 70 | 71 | updateCSPrefErr := account.UpdateCloudshellPreferences(preferredLocation, subscriptionID) 72 | 73 | if updateCSPrefErr != nil { 74 | azureWebSocket.isAvailable = false 75 | 76 | return &azureWebSocket, updateCSPrefErr 77 | } 78 | 79 | loadErr := azureWebSocket.loadWebSocketConnection() 80 | 81 | return &azureWebSocket, loadErr 82 | } 83 | 84 | func (instance *CloudShell) GetName() string { 85 | return instance.name 86 | } 87 | 88 | // Returns the current socket (and renews it if it is too old, closed or does not exist) 89 | func (instance *CloudShell) GetSocket() (*websocket.Conn, error) { 90 | if instance.isTooOld() || instance.socket == nil || instance.socketClosed { 91 | if instance.isTooOld() { 92 | if closeErr := instance.CloseCurrentSocket(); closeErr != nil { 93 | return nil, closeErr 94 | } 95 | } 96 | 97 | loadErr := instance.loadWebSocketConnection() 98 | 99 | if loadErr != nil { 100 | return nil, loadErr 101 | } 102 | } 103 | 104 | return instance.socket, nil 105 | } 106 | 107 | // Creates a new web socket (and if the max is reached, restart all of them) 108 | func (instance *CloudShell) loadWebSocketConnection() error { 109 | const maxCloudShellErr = "Exceeded 20 concurrent sessions. Please click the restart button." 110 | 111 | socket, socketErr := instance.createNewWebSocketConnection() 112 | 113 | if socketErr != nil { 114 | // If the max has been reached, restarts and recreates 115 | if strings.Contains(socketErr.Error(), maxCloudShellErr) { 116 | restartErr := instance.restartCloudShells() 117 | 118 | if restartErr != nil { 119 | return restartErr 120 | } 121 | 122 | newSocket, newSocketErr := instance.createNewWebSocketConnection() 123 | 124 | if newSocketErr != nil { 125 | return newSocketErr 126 | } 127 | 128 | instance.socket = newSocket 129 | instance.socketClosed = false 130 | instance.socketCreatedTime = time.Now().Unix() 131 | 132 | return nil 133 | } 134 | 135 | return socketErr 136 | } 137 | 138 | instance.socket = socket 139 | instance.socketClosed = false 140 | instance.socketCreatedTime = time.Now().Unix() 141 | 142 | return nil 143 | } 144 | 145 | // Restart all cloud shells (allows to renew the IP) 146 | func (instance *CloudShell) restartCloudShells() error { 147 | accessToken, accessTokenErr := instance.account.GetAccessToken([]string{"https://management.core.windows.net/user_impersonation"}) 148 | 149 | if accessTokenErr != nil { 150 | return accessTokenErr 151 | } 152 | 153 | headers := map[string]any{ 154 | "Authorization": fmt.Sprintf("Bearer %s", accessToken.Token), 155 | "Referer": "https://ux.console.azure.com", 156 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 157 | } 158 | 159 | restartURL, restartURLErr := url.Parse("https://management.azure.com/providers/Microsoft.Portal/consoles/default?api-version=2023-02-01-preview") 160 | 161 | if restartURLErr != nil { 162 | return restartURLErr 163 | } 164 | 165 | respRestart, respRestartErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 166 | URL: restartURL, 167 | Method: "DELETE", 168 | Headers: headers, 169 | Body: map[string]any{}, 170 | }) 171 | 172 | if respRestartErr != nil { 173 | return respRestartErr 174 | } 175 | 176 | if respRestart.StatusCode != http.StatusOK { 177 | return errors.New("cannot restart Cloud Shell sessions") 178 | } 179 | 180 | return nil 181 | } 182 | 183 | // Creates a web socket connection 184 | func (instance *CloudShell) createNewWebSocketConnection() (*websocket.Conn, error) { 185 | accessToken, accessTokenErr := instance.account.GetAccessToken([]string{"https://management.core.windows.net/user_impersonation"}) 186 | 187 | if accessTokenErr != nil { 188 | return nil, accessTokenErr 189 | } 190 | 191 | headers := map[string]any{ 192 | "Authorization": fmt.Sprintf("Bearer %s", accessToken.Token), 193 | "Referer": "https://ux.console.azure.com", 194 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 195 | } 196 | 197 | // Retrieving Cloud Shell URL 198 | reqCreateURL, reqCreateURLErr := url.Parse("https://management.azure.com/providers/Microsoft.Portal/consoles/default?api-version=2023-02-01-preview") 199 | 200 | if reqCreateURLErr != nil { 201 | return nil, reqCreateURLErr 202 | } 203 | 204 | reqCreateData := map[string]any{ 205 | "properties": map[string]string{ 206 | "osType": "linux", 207 | }, 208 | } 209 | 210 | respCreate, respCreateErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 211 | URL: reqCreateURL, 212 | Method: "PUT", 213 | Headers: headers, 214 | Body: reqCreateData, 215 | }) 216 | 217 | if respCreateErr != nil { 218 | return nil, respCreateErr 219 | } 220 | 221 | if respCreate.StatusCode != http.StatusOK && respCreate.StatusCode != http.StatusCreated { 222 | if errorMap, ok := respCreate.Body["error"].(map[string]any); ok { 223 | return nil, errors.New(errorMap["message"].(string)) 224 | } 225 | 226 | return nil, fmt.Errorf("%v", respCreate.Body) 227 | } 228 | 229 | properties, ok := respCreate.Body["properties"].(map[string]any) 230 | if !ok { 231 | return nil, errors.New("unable to cast properties to map") 232 | } 233 | 234 | rawCCURL, ok := properties["uri"].(string) 235 | if !ok { 236 | return nil, errors.New("unable to cast uri to string") 237 | } 238 | 239 | // Retrieving token 240 | reqCCURL, reqCCURLErr := url.Parse(rawCCURL + "/authorize") 241 | 242 | if reqCCURLErr != nil { 243 | return nil, reqCCURLErr 244 | } 245 | 246 | respGetToken, respGetTokenErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 247 | URL: reqCCURL, 248 | Method: "POST", 249 | Headers: headers, 250 | Body: map[string]any{}, 251 | }) 252 | 253 | if respGetTokenErr != nil { 254 | return nil, respGetTokenErr 255 | } 256 | 257 | token := respGetToken.Body["token"] 258 | 259 | // Retrieving websocket URL 260 | reqGetSocketURL, reqGetSocketURLErr := url.Parse(rawCCURL + "/terminals?cols=103&rows=13&version=2019-01-01&shell=bash") 261 | 262 | if reqGetSocketURLErr != nil { 263 | return nil, reqGetSocketURLErr 264 | } 265 | 266 | respGetSocketURL, respGetSocketURLErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 267 | URL: reqGetSocketURL, 268 | Method: "POST", 269 | Headers: headers, 270 | Body: map[string]any{}, 271 | }) 272 | 273 | if respGetSocketURLErr != nil { 274 | return nil, respGetSocketURLErr 275 | } 276 | 277 | if respGetSocketURL.StatusCode != http.StatusOK { 278 | return nil, errors.New(respGetSocketURL.Body["error"].(map[string]any)["message"].(string)) 279 | } 280 | 281 | // Parsing URLs 282 | socketURL, socketURLErr := url.Parse(respGetSocketURL.Body["socketUri"].(string)) 283 | 284 | if socketURLErr != nil { 285 | return nil, socketURLErr 286 | } 287 | 288 | ccURL, ccURLErr := url.Parse(rawCCURL) 289 | 290 | if ccURLErr != nil { 291 | return nil, ccURLErr 292 | } 293 | 294 | // Formatting Cloud Shell URL 295 | host := ccURL.Host 296 | path := strings.Trim(ccURL.Path, "/") 297 | id := strings.Trim(socketURL.Path, "/") 298 | 299 | shellURL := fmt.Sprintf("wss://%s/$hc/%s/terminals/%s", host, path, id) 300 | 301 | socketHeaders := http.Header{} 302 | socketHeaders.Add("Cookie", fmt.Sprintf("auth-token=%s", token)) 303 | 304 | dialer := websocket.Dialer{ 305 | Proxy: http.ProxyFromEnvironment, 306 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec 307 | } 308 | 309 | // Creates the socket connection 310 | socketConnection, _, socketConnectionErr := dialer.Dial(shellURL, socketHeaders) //nolint:bodyclose //closing process handled somewhere else 311 | 312 | if socketConnectionErr != nil { 313 | return nil, socketConnectionErr 314 | } 315 | 316 | instance.socket = socketConnection 317 | instance.socketCreatedTime = time.Now().Unix() 318 | 319 | return socketConnection, nil 320 | } 321 | 322 | func (instance *CloudShell) isTooOld() bool { 323 | return (time.Now().Unix() - instance.socketCreatedTime) > maxCloudShellLifeTime 324 | } 325 | 326 | // Waits for the cloud shell response and parses it 327 | func (instance *CloudShell) waitForCloudShellResponse() (utils.HTTPResponseData, error) { 328 | const RESP_START_PREFIX = "RESP_START" //nolint:revive,stylecheck //uppercase more readable here 329 | 330 | const RESP_ERROR_PREFIX = "RESP_ERR" //nolint:revive,stylecheck //uppercase more readable here 331 | 332 | const RESP_END_PREFIX = "RESP_END" //nolint:revive,stylecheck //uppercase more readable here 333 | 334 | const RESP_STATUS_PREFIX = "RESP_STATUS_ENC" //nolint:revive,stylecheck //uppercase more readable here 335 | 336 | const RESP_HEADERS_PREFIX = "RESP_HEADERS_ENC" //nolint:revive,stylecheck //uppercase more readable here 337 | 338 | const RESP_BODY_PREFIX = "RESP_BODY_ENC" //nolint:revive,stylecheck //uppercase more readable here 339 | 340 | fullResponseContent := "" 341 | 342 | for !instance.socketClosed { 343 | // Retrieves the last received message 344 | _, message, messageErr := instance.socket.ReadMessage() 345 | 346 | if messageErr != nil { 347 | // Additional isClosed condition in the case of the socket connection has been closed while waiting for a new message 348 | if instance.socketClosed { 349 | break 350 | } 351 | 352 | // If the socket connection has been closed 353 | if !websocket.IsUnexpectedCloseError(messageErr, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 354 | instance.socketClosed = true 355 | } 356 | 357 | utils.Logger.Error().Str("provider", instance.provider.GetName()).Str("err", messageErr.Error()).Msg("An error occurred while reading websocket message.") 358 | 359 | continue 360 | } 361 | 362 | messageStr := string(message) 363 | 364 | fullResponseContent += messageStr 365 | 366 | // If the whole information has still not been received 367 | if !(strings.Contains(fullResponseContent, RESP_START_PREFIX) && (strings.Contains(fullResponseContent, RESP_ERROR_PREFIX) || strings.Contains(fullResponseContent, RESP_END_PREFIX))) { 368 | continue 369 | } 370 | 371 | responseInfos := strings.Split(fullResponseContent, "\r\n") 372 | 373 | // If the request has succeeded 374 | if strings.Contains(fullResponseContent, "RESP_END") { 375 | // It retrieves the request status 376 | rawRespStatus := responseInfos[getSliceFirstIndexStartingWith(responseInfos, RESP_STATUS_PREFIX+" ")][(len(RESP_STATUS_PREFIX) + 1):] 377 | 378 | respStatusDecodedBytes, respStatusDecodedBytesErr := base64.StdEncoding.DecodeString(rawRespStatus) 379 | 380 | if respStatusDecodedBytesErr != nil { 381 | return utils.HTTPResponseData{}, respStatusDecodedBytesErr 382 | } 383 | 384 | respStatus, respStatusErr := strconv.Atoi(string(respStatusDecodedBytes)) 385 | 386 | if respStatusErr != nil { 387 | return utils.HTTPResponseData{}, respStatusErr 388 | } 389 | 390 | // It retrieves the response headers 391 | rawRespHeaders := responseInfos[getSliceFirstIndexStartingWith(responseInfos, RESP_HEADERS_PREFIX+" ")][(len(RESP_HEADERS_PREFIX) + 1):] 392 | 393 | respHeadersDecodedBytes, respHeadersDecodedBytesErr := base64.StdEncoding.DecodeString(rawRespHeaders) 394 | 395 | if respHeadersDecodedBytesErr != nil { 396 | return utils.HTTPResponseData{}, respHeadersDecodedBytesErr 397 | } 398 | 399 | respHeadersLines := strings.Split(string(respHeadersDecodedBytes), "\n") 400 | 401 | respHeaders := make(map[string]any) 402 | 403 | for i := 0; i < len(respHeadersLines); i += 2 { 404 | if i+1 < len(respHeadersLines) { 405 | key := respHeadersLines[i] 406 | value := respHeadersLines[i+1] 407 | respHeaders[key] = value 408 | } 409 | } 410 | 411 | // It retrieves the response body 412 | respBodyFirstIndex := getSliceFirstIndexStartingWith(responseInfos, RESP_BODY_PREFIX+" ") 413 | respBodyLastIndex := getSliceLastIndexStartingWith(responseInfos, RESP_BODY_PREFIX+" ") 414 | 415 | rawRespBody := "" 416 | for i := respBodyFirstIndex; i <= respBodyLastIndex; i++ { 417 | rawRespBody += responseInfos[i][len(RESP_BODY_PREFIX)+1:] 418 | } 419 | 420 | respBodyDecodedBytes, respBodyDecodedBytesErr := base64.StdEncoding.DecodeString(rawRespBody) 421 | 422 | if respBodyDecodedBytesErr != nil { 423 | return utils.HTTPResponseData{}, respBodyDecodedBytesErr 424 | } 425 | 426 | // Finally, it sends the response data 427 | return utils.HTTPResponseData{ 428 | StatusCode: respStatus, 429 | Headers: respHeaders, 430 | Body: respBodyDecodedBytes, 431 | }, nil 432 | } else if strings.Contains(fullResponseContent, "RESP_ERR") { // Otherwise, if the request has failed 433 | return utils.HTTPResponseData{}, fmt.Errorf("an error occurred while executing the request") 434 | } 435 | } 436 | 437 | return utils.HTTPResponseData{}, errors.New("the websocket connection has been closed") 438 | } 439 | 440 | func (account *Account) UpdateCloudshellPreferences(preferredLocation, subscriptionID string) error { 441 | token, tokenErr := account.GetAccessToken([]string{"https://management.core.windows.net/.default"}) 442 | 443 | if tokenErr != nil { 444 | return tokenErr 445 | } 446 | 447 | headers := map[string]any{ 448 | "Authorization": utils.PrepareBearerHeader(token.Token), 449 | "Accept": "*/*", 450 | "Content-Type": "application/json", 451 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5666.197 Safari/537.36", 452 | } 453 | 454 | reqURL, reqURLErr := url.Parse("https://management.azure.com/providers/Microsoft.Portal/userSettings/cloudconsole?api-version=2023-02-01-preview") 455 | 456 | if reqURLErr != nil { 457 | return reqURLErr 458 | } 459 | 460 | reqData := map[string]any{ 461 | "properties": map[string]any{ 462 | "preferredOsType": "", 463 | "preferredLocation": preferredLocation, 464 | "storageProfile": nil, 465 | "terminalSettings": map[string]any{ 466 | "fontSize": "medium", 467 | "fontStyle": "monospace", 468 | }, 469 | "vnetSettings": nil, 470 | "userSubscription": subscriptionID, 471 | "sessionType": "Ephemeral", 472 | "networkType": "Default", 473 | "preferredShellType": "bash", 474 | }, 475 | } 476 | 477 | resp, respErr := utils.SendJSONRequest(utils.HTTPRequestJSONData{ 478 | URL: reqURL, 479 | Method: "PUT", 480 | Headers: headers, 481 | Body: reqData, 482 | }) 483 | 484 | if respErr != nil { 485 | return respErr 486 | } 487 | 488 | if resp.StatusCode != http.StatusOK { 489 | return fmt.Errorf("cannot update %s user account's cloudshell preferences", account.username) 490 | } 491 | 492 | return nil 493 | } 494 | 495 | func PrepareCloudShellCommand(reqData utils.HTTPRequestData) string { 496 | // It prepares and encodes the request headers to a base64 format 497 | headersStr := "" 498 | 499 | if reqData.Headers != nil { 500 | for headerKey, headerVal := range reqData.Headers { 501 | headersStr += headerKey + "\n" + fmt.Sprintf("%v", headerVal) + "\n" 502 | } 503 | 504 | if len(headersStr) > 0 { 505 | headersStr = headersStr[:(len(headersStr) - 1)] 506 | } 507 | } 508 | 509 | // It prepares and encodes the request body to a base64 format 510 | bodyStr := "" 511 | 512 | if reqData.Body != nil { 513 | bodyStr = reqData.Body.String() 514 | } 515 | 516 | cmd := fmt.Sprintf("pip3 install requests && python3 -c \"$(echo '%s' | base64 --decode)\" %s %s %s %s", 517 | SCRIPT_PY_FILE_BASE64, 518 | base64.StdEncoding.EncodeToString([]byte(reqData.Method)), 519 | base64.StdEncoding.EncodeToString([]byte(reqData.URL.String())), 520 | base64.StdEncoding.EncodeToString([]byte(headersStr)), 521 | base64.StdEncoding.EncodeToString([]byte(bodyStr))) 522 | 523 | return cmd 524 | } 525 | 526 | func (instance *CloudShell) SendRequest(ctx context.Context, reqData utils.HTTPRequestData, allConfigs *utils.AllConfigs) (utils.HTTPResponseData, string, error) { //nolint:revive 527 | // Sets this cloud shell unavailable to next requests 528 | instance.isAvailable = false 529 | 530 | cmd := PrepareCloudShellCommand(reqData) 531 | 532 | socket, socketErr := instance.GetSocket() 533 | 534 | if socketErr != nil { 535 | return utils.HTTPResponseData{}, fmt.Sprintf("location=%s", instance.preferredLocation), nil 536 | } 537 | 538 | // It sends the command 539 | writeErr := socket.WriteMessage(websocket.TextMessage, []byte(cmd+"\n")) 540 | 541 | if writeErr != nil { 542 | return utils.HTTPResponseData{}, fmt.Sprintf("location=%s", instance.preferredLocation), writeErr 543 | } 544 | 545 | instance.nbTotalRequestsSent++ 546 | 547 | resp, respErr := instance.waitForCloudShellResponse() 548 | 549 | // In a go routine (to avoid waiting to send the response), it closes and renews the connection 550 | go func(instance *CloudShell) { 551 | closeErr := instance.CloseCurrentSocket() 552 | 553 | restartErr := instance.restartCloudShells() 554 | 555 | loadErr := instance.loadWebSocketConnection() 556 | 557 | if closeErr != nil { 558 | utils.Logger.Warn().Err(closeErr).Str("provider", instance.provider.GetName()).Msg("Cannot close web socket connection.") 559 | } 560 | 561 | if restartErr != nil { 562 | utils.Logger.Warn().Err(restartErr).Str("provider", instance.provider.GetName()).Msg("Cannot restart Cloud Shells.") 563 | } 564 | 565 | if loadErr != nil { 566 | utils.Logger.Warn().Err(loadErr).Str("provider", instance.provider.GetName()).Msg("Cannot load a new web socket connection.") 567 | } 568 | 569 | instance.isAvailable = true 570 | }(instance) 571 | 572 | if respErr != nil { 573 | return utils.HTTPResponseData{}, fmt.Sprintf("location=%s", instance.preferredLocation), respErr 574 | } 575 | 576 | return resp, fmt.Sprintf("location=%s", instance.preferredLocation), nil 577 | } 578 | 579 | // Closes current socket connection 580 | func (instance *CloudShell) CloseCurrentSocket() error { 581 | if instance.socketClosed || instance.socket == nil { 582 | return nil 583 | } 584 | 585 | instance.socketClosed = true 586 | 587 | writeErr := instance.socket.WriteMessage(websocket.TextMessage, []byte("exit")) 588 | 589 | utils.Logger.Debug().Str("provider", instance.provider.GetName()).Msg("Closing web socket connection.") 590 | 591 | if writeErr != nil { 592 | instance.socketClosed = false 593 | 594 | return writeErr 595 | } 596 | 597 | closeErr := instance.socket.Close() 598 | 599 | if closeErr != nil { 600 | instance.socketClosed = false 601 | 602 | return closeErr 603 | } 604 | 605 | return nil 606 | } 607 | 608 | func (instance *CloudShell) IsAvailable() bool { 609 | return instance.isAvailable 610 | } 611 | 612 | func (instance *CloudShell) IsStopped() bool { 613 | return instance.stopped 614 | } 615 | 616 | //nolint:revive 617 | func (instance *CloudShell) PreloadHosts(ctx context.Context, allPreloadHosts []*url.URL) { 618 | utils.Logger.Info().Str("provider", instance.GetName()).Msg("This provider cannot preload hosts.") 619 | } 620 | 621 | func (instance *CloudShell) GetProvider() utils.Provider { 622 | return instance.provider 623 | } 624 | 625 | func (instance *CloudShell) Clear() bool { 626 | utils.Logger.Info().Str("launcher", instance.GetName()).Msg("Clearing launcher.") 627 | 628 | fullyCleared := true 629 | 630 | closeErr := instance.CloseCurrentSocket() 631 | 632 | if closeErr != nil { 633 | fullyCleared = false 634 | 635 | utils.Logger.Error().Err(closeErr).Str("provider", instance.GetProvider().GetName()).Str("launcher", instance.GetName()).Msg("Cannot close the socket.") 636 | } 637 | 638 | if instance.account.needToBeCleared { 639 | utils.Logger.Debug().Str("account", instance.account.username).Msg("Deleting Azure account and associated role assignments.") 640 | 641 | deleteAssignmentsErr := instance.provider.GetAdminAccount().DeleteCreatedRoleAssignments(instance.account) 642 | 643 | if deleteAssignmentsErr != nil { 644 | fullyCleared = false 645 | 646 | utils.Logger.Error().Err(deleteAssignmentsErr).Str("provider", instance.GetProvider().GetName()).Str("launcher", instance.GetName()).Str("username", instance.account.username).Msg("Cannot delete account's role assignments.") 647 | } 648 | 649 | deleteAccountErr := instance.provider.GetAdminAccount().DeleteAccount(instance.account) 650 | 651 | if deleteAccountErr != nil { 652 | fullyCleared = false 653 | 654 | utils.Logger.Error().Err(deleteAccountErr).Str("provider", instance.GetProvider().GetName()).Str("launcher", instance.GetName()).Str("username", instance.account.username).Msg("Cannot delete account.") 655 | } 656 | } 657 | 658 | instance.stopped = fullyCleared 659 | 660 | return fullyCleared 661 | } 662 | 663 | func (instance CloudShell) GetNbTotalReqSent() int { 664 | return instance.nbTotalRequestsSent 665 | } 666 | 667 | func getSliceFirstIndexStartingWith(slice []string, startsWith string) int { 668 | for i, line := range slice { 669 | if strings.HasPrefix(line, startsWith) { 670 | return i 671 | } 672 | } 673 | 674 | return -1 675 | } 676 | 677 | func getSliceLastIndexStartingWith(slice []string, startsWith string) int { 678 | for i := (len(slice) - 1); i >= 0; i-- { 679 | line := slice[i] 680 | if strings.HasPrefix(line, startsWith) { 681 | return i 682 | } 683 | } 684 | 685 | return -1 686 | } 687 | -------------------------------------------------------------------------------- /providers/aws/fireprox.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "ipspinner/utils" 10 | "net/http" 11 | "net/url" 12 | "slices" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | awssdk "github.com/aws/aws-sdk-go-v2/aws" 18 | "github.com/aws/aws-sdk-go-v2/service/apigateway" 19 | "github.com/aws/aws-sdk-go-v2/service/apigateway/types" 20 | ) 21 | 22 | /* 23 | All credit to the peeps as Black Hills Information Security, this is simply a Golang implementation of their FireProx tool. 24 | https://github.com/ustayready/fireprox 25 | */ 26 | 27 | // Launcher object 28 | type FireProx struct { 29 | provider *AWS 30 | apiGatewaysPerRegion map[string][]*APIGateway 31 | creatingAPIGatewayInstanceInRegion []string // contains regions 32 | maxAPIGatewayInstances int 33 | fireProxTitlePrefix string 34 | fireProxDeploymentDescription string 35 | fireProxDeploymentStageDescription string 36 | fireProxDeploymentStageName string 37 | rotateAPIGateway int // nb requests for rotating 38 | stopped bool 39 | } 40 | 41 | type APIGateway struct { 42 | launcher *FireProx 43 | awsConfig *awssdk.Config 44 | AllRegisteredURLs []*url.URL 45 | Title string 46 | RestAPIID string 47 | Deleted bool 48 | Updating bool 49 | Deleting bool 50 | NbReqSent int 51 | } 52 | 53 | func CreateFireProx(provider *AWS, maxFPInstances int, fpTitlePrefix, fpDeploymentDescription, fpDeploymentStageDescription, fpDeploymentStageName string, rotateAPIGateway int) (*FireProx, error) { 54 | instance := FireProx{ 55 | provider: provider, 56 | apiGatewaysPerRegion: make(map[string][]*APIGateway, 0), 57 | creatingAPIGatewayInstanceInRegion: make([]string, 0), 58 | maxAPIGatewayInstances: maxFPInstances, 59 | fireProxTitlePrefix: fpTitlePrefix, 60 | fireProxDeploymentDescription: fpDeploymentDescription, 61 | fireProxDeploymentStageDescription: fpDeploymentStageDescription, 62 | fireProxDeploymentStageName: fpDeploymentStageName, 63 | rotateAPIGateway: rotateAPIGateway, 64 | } 65 | 66 | utils.Logger.Info().Str("launcher", instance.GetName()).Msg("Creating launcher.") 67 | 68 | return &instance, nil 69 | } 70 | 71 | //nolint:revive 72 | func (instance FireProx) GetName() string { 73 | return "FireProx (via API Gateways)" 74 | } 75 | 76 | func (instance *FireProx) GetProvider() utils.Provider { 77 | return instance.provider 78 | } 79 | 80 | func (instance FireProx) GetNbTotalReqSent() int { 81 | nbTotal := 0 82 | 83 | for _, gateways := range instance.apiGatewaysPerRegion { 84 | for _, gateway := range gateways { 85 | nbTotal += gateway.NbReqSent 86 | } 87 | } 88 | 89 | return nbTotal 90 | } 91 | 92 | func (instance FireProx) SummarizeState() string { 93 | return fmt.Sprintf("Launcher %s : nbTotalRequestsSent=%d, nbAPIGateways=%d", instance.GetName(), instance.GetNbTotalReqSent(), instance.getNbAPIGatewayInstances()) 94 | } 95 | 96 | // Returns the number of configured and not deleted FireProx instances 97 | func (instance FireProx) getNbAPIGatewayInstances() int { 98 | nb := 0 99 | 100 | for _, instances := range instance.apiGatewaysPerRegion { 101 | for _, instance := range instances { 102 | if !instance.Deleted && !instance.Deleting { 103 | nb++ 104 | } 105 | } 106 | } 107 | 108 | return nb 109 | } 110 | 111 | // Preloads all given hosts by creating one or multiple FireProx instances with all URLs ready to fetch 112 | func (instance *FireProx) PreloadHosts(ctx context.Context, allPreloadHosts []*url.URL) { 113 | for _, awsConfig := range instance.provider.awsConfigs { 114 | // If the maximum of created instances has been reached 115 | if instance.getNbAPIGatewayInstances() >= instance.maxAPIGatewayInstances { 116 | utils.Logger.Warn().Int("nbAPIGatewayInstancesRunning", instance.getNbAPIGatewayInstances()).Int("maxAPIGatewayInstances", instance.maxAPIGatewayInstances).Str("region", awsConfig.Region).Msg("Can not preload hosts for AWS in this region because the maximum number of FireProx instances has been reached.") 117 | 118 | continue 119 | } 120 | 121 | title := fmt.Sprintf("%s_%s", instance.fireProxTitlePrefix, strconv.FormatInt(time.Now().UnixMicro(), 10)) //nolint:revive 122 | 123 | // If more than utils.MaxResourcePerFireProxInstance need to be added, it subdivises them into multiple sublists for creating multiple instances 124 | subdivisedPreloadHosts := utils.SubdiviseSlice(allPreloadHosts, utils.MaxResourcePerFireProxInstance) 125 | 126 | for _, preloadHosts := range subdivisedPreloadHosts { 127 | if instance.getNbAPIGatewayInstances() >= instance.maxAPIGatewayInstances { 128 | utils.Logger.Warn().Int("max", instance.maxAPIGatewayInstances).Msg("The maximum number of API Gateway instances has been reached, can not create new ones for the remaining preloading hosts.") 129 | 130 | continue 131 | } 132 | 133 | // It creates a new FireProx instance 134 | newAPIGatewayInstance, newAPIGatewayInstanceErr := instance.CreateAPIGateway(ctx, awsConfig, preloadHosts, title) 135 | 136 | // If it can not create the instance, it rejects the request 137 | if newAPIGatewayInstanceErr != nil { 138 | utils.Logger.Warn().Err(newAPIGatewayInstanceErr).Str("region", awsConfig.Region).Msg("Can not create the API Gateway instance for the preloading hosts in this region.") 139 | 140 | continue 141 | } 142 | 143 | utils.Logger.Debug().Int("preloadHostsNb", len(preloadHosts)).Str("region", awsConfig.Region).Str("createdInstanceID", newAPIGatewayInstance.RestAPIID).Msg("Creating API Gateway instance for preloading hosts.") 144 | 145 | // It stores the FireProx instance 146 | instance.apiGatewaysPerRegion[awsConfig.Region] = append(utils.GetOrDefault(instance.apiGatewaysPerRegion, awsConfig.Region, []*APIGateway{}), newAPIGatewayInstance) 147 | } 148 | } 149 | } 150 | 151 | // Creates an API Gateway instance 152 | func (instance *FireProx) CreateAPIGateway(ctx context.Context, awsConfig *awssdk.Config, urls []*url.URL, title string) (*APIGateway, error) { 153 | if len(urls) > utils.MaxResourcePerFireProxInstance { 154 | return nil, fmt.Errorf("can not create a FireProx instance with this number of resources (%d)", len(urls)) 155 | } 156 | 157 | client := apigateway.NewFromConfig(*awsConfig) 158 | 159 | templateBytes := APIGateway{ 160 | launcher: instance, 161 | Title: title, 162 | AllRegisteredURLs: urls, 163 | awsConfig: awsConfig, 164 | }.generateOpenAPISpecification() 165 | 166 | importRestAPIResp, importRestAPIErr := client.ImportRestApi(ctx, &apigateway.ImportRestApiInput{ 167 | Body: templateBytes, 168 | Parameters: map[string]string{ 169 | "endpointConfigurationTypes": "REGIONAL", 170 | }, 171 | }, apigateway.WithAPIOptions()) 172 | 173 | if importRestAPIErr != nil { 174 | return nil, importRestAPIErr 175 | } 176 | 177 | deploymentDescription := instance.fireProxDeploymentDescription 178 | deploymentStageDescription := instance.fireProxDeploymentStageDescription 179 | deploymentStageName := instance.fireProxDeploymentStageName 180 | 181 | _, createDeploymentErr := client.CreateDeployment(ctx, &apigateway.CreateDeploymentInput{ 182 | Description: &deploymentDescription, 183 | StageDescription: &deploymentStageDescription, 184 | StageName: &deploymentStageName, 185 | RestApiId: importRestAPIResp.Id, 186 | }, apigateway.WithAPIOptions()) 187 | 188 | if createDeploymentErr != nil { 189 | return nil, createDeploymentErr 190 | } 191 | 192 | apiGateway := APIGateway{ 193 | launcher: instance, 194 | Title: title, 195 | Deleted: false, 196 | RestAPIID: *importRestAPIResp.Id, 197 | awsConfig: awsConfig, 198 | AllRegisteredURLs: urls, 199 | NbReqSent: 0, 200 | Updating: false, 201 | } 202 | 203 | return &apiGateway, nil 204 | } 205 | 206 | // It returns a list in which for each region, it stores a FireProx instance that already targets the URL or if there are no ones, an instance which still can receive new URLs 207 | func (instance *FireProx) GetOneAPIGatewayInstancesEachRegionCanTargetURL(url1 *url.URL) []*APIGateway { 208 | apiGatewayInstances := []*APIGateway{} 209 | 210 | for region := range instance.apiGatewaysPerRegion { 211 | apiGateway := instance.GetAPIGatewayInstanceCanTargetURLInRegion(url1, region) 212 | 213 | if apiGateway != nil { 214 | apiGatewayInstances = append(apiGatewayInstances, apiGateway) 215 | } 216 | } 217 | 218 | return apiGatewayInstances 219 | } 220 | 221 | // func (instance *APIGateway) WaitTillAvailable(maxSeconds int) bool { 222 | // count := 0 223 | 224 | // for !instance.Available && count < (maxSeconds/0.05) { 225 | // count += 1 226 | 227 | // time.Sleep(50 * time.Millisecond) 228 | // } 229 | 230 | // return instance.Available 231 | // } 232 | 233 | // It returns a FireProx instance that already targets the URL or if there are no ones, an instance which still can receive new URLs 234 | func (instance *FireProx) GetAPIGatewayInstanceCanTargetURLInRegion(url1 *url.URL, targetRegion string) *APIGateway { 235 | allInstancesInRegion := instance.apiGatewaysPerRegion[targetRegion] 236 | 237 | slices.Reverse(allInstancesInRegion) // to avoid race conditions while renewing 238 | 239 | var availableInstance *APIGateway 240 | 241 | for _, apiGateway := range allInstancesInRegion { 242 | if apiGateway.Deleted || apiGateway.Deleting { 243 | continue 244 | } 245 | 246 | if apiGateway.DoesTargetURL(url1) { 247 | return apiGateway 248 | } else if apiGateway.CanStillIncrease() { 249 | availableInstance = apiGateway 250 | } 251 | } 252 | 253 | return availableInstance 254 | } 255 | 256 | // Sends a request through one of the FireProx instances 257 | func (instance *FireProx) SendRequest(ctx context.Context, requestData utils.HTTPRequestData, allConfigs *utils.AllConfigs) (utils.HTTPResponseData, string, error) { 258 | // Retrieves one available instance for this job 259 | apiGatewayInstance, apiGatewayInstanceErr := instance.getAPIGatewayInstance(ctx, requestData) 260 | 261 | if apiGatewayInstanceErr != nil { 262 | return utils.HTTPResponseData{}, "", apiGatewayInstanceErr 263 | } 264 | 265 | apiGatewayURL, apiGatewayURLErr := apiGatewayInstance.GetURLForReachingGivenURL(requestData.URL) 266 | 267 | if apiGatewayURLErr != nil { 268 | return utils.HTTPResponseData{}, fmt.Sprintf("apiGatewayID=%s, region=%s", apiGatewayInstance.RestAPIID, apiGatewayInstance.awsConfig.Region), apiGatewayURLErr 269 | } 270 | 271 | headers := map[string]any{} 272 | 273 | if requestData.Headers != nil { 274 | headers = requestData.Headers 275 | } 276 | 277 | // Creates a random IP from the given CIDR 278 | randomIPFromRange, randomIPFromRangeErr := utils.RandomIPFromCIDR(allConfigs.ProvidersConfig.AWSAGForwardedForRange) 279 | 280 | if randomIPFromRangeErr != nil { 281 | return utils.HTTPResponseData{}, fmt.Sprintf("apiGatewayID=%s, region=%s", apiGatewayInstance.RestAPIID, apiGatewayInstance.awsConfig.Region), randomIPFromRangeErr 282 | } 283 | 284 | // Sets the random IP as the proxy source 285 | headers["X-My-X-Forwarded-For"] = randomIPFromRange 286 | 287 | body := &bytes.Buffer{} 288 | 289 | if requestData.Body != nil { 290 | body = requestData.Body 291 | } 292 | 293 | resp, respErr := utils.SendRequest(utils.HTTPRequestData{ 294 | URL: apiGatewayURL, 295 | Method: requestData.Method, 296 | Body: body, 297 | Headers: headers, 298 | }) 299 | 300 | if respErr != nil { 301 | return utils.HTTPResponseData{}, fmt.Sprintf("apiGatewayID=%s, xForwardedFor=%s, region=%s", apiGatewayInstance.RestAPIID, randomIPFromRange.String(), apiGatewayInstance.awsConfig.Region), respErr 302 | } 303 | 304 | // Increments the number of request sent with this API Gateway instance 305 | apiGatewayInstance.NbReqSent++ 306 | 307 | return resp, fmt.Sprintf("apiGatewayID=%s, xForwardedFor=%s, region=%s", apiGatewayInstance.RestAPIID, randomIPFromRange.String(), apiGatewayInstance.awsConfig.Region), nil 308 | } 309 | 310 | // Deletes the FireProx instance (all remaining API Gateways) 311 | func (instance *FireProx) Clear() bool { 312 | utils.Logger.Info().Str("launcher", instance.GetName()).Msg("Clearing launcher.") 313 | 314 | result := true 315 | 316 | for _, apiGatewayInstances := range instance.apiGatewaysPerRegion { 317 | for _, apiGatewayInstance := range apiGatewayInstances { 318 | if apiGatewayInstance.Deleted { 319 | continue 320 | } 321 | 322 | utils.Logger.Debug().Str("apiGatewayInstanceID", apiGatewayInstance.RestAPIID).Msg("Deleting API Gateway instance.") 323 | 324 | if err := apiGatewayInstance.Delete(); err != nil { 325 | utils.Logger.Error().Err(err).Str("apiGatewayInstanceID", apiGatewayInstance.RestAPIID).Msg("Error while deleting API Gateway instance.") 326 | 327 | result = false 328 | } 329 | } 330 | } 331 | 332 | instance.stopped = result 333 | 334 | return result 335 | } 336 | 337 | //nolint:revive 338 | func (instance *FireProx) IsAvailable() bool { 339 | return true 340 | } 341 | 342 | func (instance *FireProx) IsStopped() bool { 343 | return instance.stopped 344 | } 345 | 346 | // Tries to return a FireProx instance which can target the URL in the requestData 347 | // - If new instances can be created 348 | // 349 | // - Chooses a random region 350 | // 351 | // - It retrieves an available instance in the region (which already targets the url or which can accept new urls) 352 | // 353 | // - If no instance is available, it checks if a new one is already being created 354 | // 355 | // - If there is still no instance available 356 | // 357 | // - It creates a new one 358 | // 359 | // - Else (if an instance was available) 360 | // 361 | // - If it does not target the URL, it adds it 362 | // 363 | // - Else 364 | // 365 | // - It takes a random available instance in all regions 366 | // 367 | // - If there are no available instance => raise error 368 | // 369 | // - Else => adds url if necessary 370 | // 371 | // It also handles instance renewing 372 | func (instance *FireProx) getAPIGatewayInstance(ctx context.Context, requestData utils.HTTPRequestData) (*APIGateway, error) { 373 | var apiGatewayInstance *APIGateway 374 | 375 | // If new instances can be created 376 | if instance.getNbAPIGatewayInstances() < instance.maxAPIGatewayInstances { 377 | // Chooses a random region among all available awssdk.Config 378 | chosenRegion := utils.RandomKeyOfMap(instance.provider.awsConfigs) 379 | 380 | // It retrieves an instance in the region which already targets the URL or which is still not full to add URLs 381 | apiGateway := instance.GetAPIGatewayInstanceCanTargetURLInRegion(requestData.URL, chosenRegion) 382 | 383 | // If no instance was found, it checks if an instance is already being created 384 | if apiGateway == nil && slices.Contains(instance.creatingAPIGatewayInstanceInRegion, chosenRegion) { 385 | // If a FireProx instance is already being created in the region, it waits for it 386 | for slices.Contains(instance.creatingAPIGatewayInstanceInRegion, chosenRegion) { 387 | time.Sleep(50 * time.Millisecond) //nolint:gomnd 388 | } 389 | 390 | apiGateway = instance.GetAPIGatewayInstanceCanTargetURLInRegion(requestData.URL, chosenRegion) 391 | } 392 | 393 | // If no instance is available, it creates a new one 394 | if apiGateway == nil { 395 | // It temporarily stores the region into 396 | instance.creatingAPIGatewayInstanceInRegion = append(instance.creatingAPIGatewayInstanceInRegion, chosenRegion) 397 | 398 | title := fmt.Sprintf("%s_%s", instance.fireProxTitlePrefix, strconv.FormatInt(time.Now().UnixMicro(), 10)) //nolint:revive 399 | 400 | // It creates a new API Gateway instance 401 | newAPIGatewayInstance, newAPIGatewayInstanceErr := instance.CreateAPIGateway(ctx, instance.provider.awsConfigs[chosenRegion], []*url.URL{requestData.URL}, title) 402 | 403 | // If it can not create the instance, it rejects the request 404 | // In addition, it removes the creation from creatingAPIGatewayInstanceInRegion 405 | if newAPIGatewayInstanceErr != nil { 406 | instance.creatingAPIGatewayInstanceInRegion = utils.DeleteElementFromSlice(instance.creatingAPIGatewayInstanceInRegion, chosenRegion) 407 | 408 | return nil, newAPIGatewayInstanceErr 409 | } 410 | 411 | apiGateway = newAPIGatewayInstance 412 | 413 | utils.Logger.Debug().Str("url", requestData.URL.String()).Str("region", chosenRegion).Str("createdInstanceID", newAPIGatewayInstance.RestAPIID).Msg("Creating API Gateway instance.") 414 | 415 | // It stores the API Gateway instance 416 | instance.apiGatewaysPerRegion[chosenRegion] = append(utils.GetOrDefault(instance.apiGatewaysPerRegion, chosenRegion, []*APIGateway{}), apiGateway) 417 | 418 | // It removes it from the creation list 419 | instance.creatingAPIGatewayInstanceInRegion = utils.DeleteElementFromSlice(instance.creatingAPIGatewayInstanceInRegion, chosenRegion) 420 | } else if !apiGateway.DoesTargetURL(requestData.URL) { // Otherwise, if an instance is available but does not already targets the URL, it adds it 421 | apiGatewayErr := apiGateway.AddNewURL(ctx, requestData.URL) 422 | 423 | if apiGatewayErr != nil { 424 | return nil, apiGatewayErr 425 | } 426 | } 427 | 428 | apiGatewayInstance = apiGateway 429 | } else { // Otherwise, it retrieves the instances that already target the URL or the instances which can receive new URLs in the other regions 430 | apiGatewayInstancesAvailable := instance.GetOneAPIGatewayInstancesEachRegionCanTargetURL(requestData.URL) 431 | 432 | if len(apiGatewayInstancesAvailable) == 0 { 433 | return nil, errors.New("the maximum number of FireProx instances has been reached and no previous instance can target this URL because they have reached the maximum number of resources per instance") 434 | } 435 | 436 | apiGatewayInstance = utils.RandomElementInSlice(apiGatewayInstancesAvailable) 437 | 438 | if !apiGatewayInstance.DoesTargetURL(requestData.URL) { 439 | apiGatewayErr := apiGatewayInstance.AddNewURL(ctx, requestData.URL) 440 | 441 | if apiGatewayErr != nil { 442 | return nil, apiGatewayErr 443 | } 444 | } 445 | } 446 | 447 | // If the instance needs to be rotated 448 | if instance.rotateAPIGateway > 0 && apiGatewayInstance.NbReqSent > 0 && apiGatewayInstance.NbReqSent%instance.rotateAPIGateway == 0 { 449 | region := apiGatewayInstance.awsConfig.Region 450 | 451 | // Considers it deleted to avoid race conditions (multiple renews before it gets deleted) 452 | apiGatewayInstance.Deleting = true 453 | instance.creatingAPIGatewayInstanceInRegion = append(instance.creatingAPIGatewayInstanceInRegion, region) //nolint:wsl 454 | 455 | // It renews it 456 | newAPIGatewayInstance, newAPIGatewayInstanceErr := apiGatewayInstance.Renew() 457 | 458 | if newAPIGatewayInstanceErr != nil { 459 | return nil, newAPIGatewayInstanceErr 460 | } 461 | 462 | utils.Logger.Debug().Str("region", region).Str("previousInstanceID", apiGatewayInstance.RestAPIID).Str("newInstanceID", newAPIGatewayInstance.RestAPIID).Str("url", requestData.URL.String()).Msg("Renewing API Gateway instance.") 463 | 464 | instance.apiGatewaysPerRegion[region] = append(instance.apiGatewaysPerRegion[region], newAPIGatewayInstance) 465 | 466 | instance.creatingAPIGatewayInstanceInRegion = utils.DeleteElementFromSlice(instance.creatingAPIGatewayInstanceInRegion, region) 467 | 468 | // It deletes the previous instance in a go routine 469 | instanceToDelete := apiGatewayInstance 470 | 471 | go func() { 472 | deleteErr := instanceToDelete.Delete() 473 | 474 | if deleteErr != nil { 475 | utils.Logger.Error().Err(deleteErr).Str("instanceID", instanceToDelete.RestAPIID).Msg("Can not delete API Gateway instance.") 476 | } 477 | }() 478 | 479 | apiGatewayInstance = newAPIGatewayInstance 480 | } 481 | 482 | return apiGatewayInstance, nil 483 | } 484 | 485 | func (instance APIGateway) generateOpenAPISpecification() []byte { 486 | specification := ` 487 | { 488 | "swagger": "2.0", 489 | "info": { 490 | "version": "{{version_date}}", 491 | "title": "{{title}}" 492 | }, 493 | "basePath": "/", 494 | "schemes": [ 495 | "https" 496 | ], 497 | "paths": { 498 | "/": { 499 | "get": { 500 | "parameters": [ 501 | { 502 | "name": "proxy", 503 | "in": "path", 504 | "required": true, 505 | "type": "string" 506 | }, 507 | { 508 | "name": "X-My-X-Forwarded-For", 509 | "in": "header", 510 | "required": false, 511 | "type": "string" 512 | } 513 | ], 514 | "responses": {}, 515 | "x-amazon-apigateway-integration": { 516 | "uri": "https://amazon.com/", 517 | "responses": { 518 | "default": { 519 | "statusCode": "200" 520 | } 521 | }, 522 | "requestParameters": { 523 | "integration.request.path.proxy": "method.request.path.proxy", 524 | "integration.request.header.X-Forwarded-For": "method.request.header.X-My-X-Forwarded-For" 525 | }, 526 | "passthroughBehavior": "when_no_match", 527 | "httpMethod": "ANY", 528 | "tlsConfig" : { 529 | "insecureSkipVerification" : true 530 | }, 531 | "cacheNamespace": "{{cacheNamespace}}", 532 | "cacheKeyParameters": [ 533 | "method.request.path.proxy" 534 | ], 535 | "type": "http_proxy" 536 | } 537 | } 538 | } 539 | {{baseURLsPart}} 540 | } 541 | } 542 | ` 543 | 544 | baseURLsPart := "" 545 | 546 | for _, urlTo := range instance.AllRegisteredURLs { 547 | toAdd := `, 548 | "/{{identifier}}/": { 549 | "x-amazon-apigateway-any-method": { 550 | "produces": [ 551 | "application/json" 552 | ], 553 | "parameters": [ 554 | { 555 | "name": "proxy", 556 | "in": "path", 557 | "required": true, 558 | "type": "string" 559 | }, 560 | { 561 | "name": "X-My-X-Forwarded-For", 562 | "in": "header", 563 | "required": false, 564 | "type": "string" 565 | } 566 | ], 567 | "responses": {}, 568 | "x-amazon-apigateway-integration": { 569 | "uri": "{{baseURL}}/{proxy}", 570 | "responses": { 571 | "default": { 572 | "statusCode": "200" 573 | } 574 | }, 575 | "requestParameters": { 576 | "integration.request.path.proxy": "method.request.path.proxy", 577 | "integration.request.header.X-Forwarded-For": "method.request.header.X-My-X-Forwarded-For" 578 | }, 579 | "passthroughBehavior": "when_no_match", 580 | "httpMethod": "ANY", 581 | "tlsConfig" : { 582 | "insecureSkipVerification" : true 583 | }, 584 | "cacheNamespace": "{{cacheNamespace}}", 585 | "cacheKeyParameters": [ 586 | "method.request.path.proxy" 587 | ], 588 | "type": "http_proxy" 589 | } 590 | } 591 | }, 592 | "/{{identifier}}/{proxy+}": { 593 | "x-amazon-apigateway-any-method": { 594 | "produces": [ 595 | "application/json" 596 | ], 597 | "parameters": [ 598 | { 599 | "name": "proxy", 600 | "in": "path", 601 | "required": true, 602 | "type": "string" 603 | }, 604 | { 605 | "name": "X-My-X-Forwarded-For", 606 | "in": "header", 607 | "required": false, 608 | "type": "string" 609 | } 610 | ], 611 | "responses": {}, 612 | "x-amazon-apigateway-integration": { 613 | "uri": "{{baseURL}}/{proxy}", 614 | "responses": { 615 | "default": { 616 | "statusCode": "200" 617 | } 618 | }, 619 | "requestParameters": { 620 | "integration.request.path.proxy": "method.request.path.proxy", 621 | "integration.request.header.X-Forwarded-For": "method.request.header.X-My-X-Forwarded-For" 622 | }, 623 | "passthroughBehavior": "when_no_match", 624 | "httpMethod": "ANY", 625 | "tlsConfig" : { 626 | "insecureSkipVerification" : true 627 | }, 628 | "cacheNamespace": "irx7tm", 629 | "cacheKeyParameters": [ 630 | "method.request.path.proxy" 631 | ], 632 | "type": "http_proxy" 633 | } 634 | } 635 | }` 636 | 637 | baseURL := utils.GetBaseURL(urlTo) 638 | 639 | identifier, identifierErr := getAPIGatewayURLIdentifierForReachingGivenURL(urlTo) 640 | 641 | if identifierErr != nil { 642 | utils.Logger.Error().Err(identifierErr).Str("urlTo", urlTo.String()).Msg("Can not parse base URL for adding it to the API Gateway config.") 643 | 644 | continue 645 | } 646 | 647 | toAdd = strings.ReplaceAll(toAdd, "{{identifier}}", identifier) 648 | toAdd = strings.ReplaceAll(toAdd, "{{baseURL}}", baseURL) 649 | 650 | baseURLsPart += toAdd 651 | } 652 | 653 | cacheNamespace := utils.GenerateRandomPrefix(7) //nolint:revive,gomnd 654 | 655 | currentTime := time.Now() 656 | 657 | versionDate := currentTime.Format("2006-01-02T15:04:05Z") 658 | 659 | specification = strings.ReplaceAll(specification, "{{title}}", strings.ReplaceAll(instance.Title, " ", "_")) 660 | specification = strings.ReplaceAll(specification, "{{version_date}}", versionDate) 661 | specification = strings.ReplaceAll(specification, "{{baseURLsPart}}", baseURLsPart) 662 | specification = strings.ReplaceAll(specification, "{{cacheNamespace}}", cacheNamespace) 663 | 664 | return []byte(specification) 665 | } 666 | 667 | // Deletes the given instance 668 | func (instance *APIGateway) Delete() error { 669 | client := apigateway.NewFromConfig(*instance.awsConfig) 670 | 671 | _, deleteErr := client.DeleteRestApi(context.Background(), &apigateway.DeleteRestApiInput{ 672 | RestApiId: &instance.RestAPIID, 673 | }, apigateway.WithAPIOptions()) 674 | 675 | if deleteErr != nil { 676 | return deleteErr 677 | } 678 | 679 | instance.Deleted = true 680 | 681 | return nil 682 | } 683 | 684 | // Returns the API Gateway REST API endpoint URL 685 | func (instance APIGateway) GetAPIGatewayStageURL() (*url.URL, error) { 686 | return url.Parse(fmt.Sprintf("https://%s.execute-api.%s.amazonaws.com/%s/", instance.RestAPIID, instance.awsConfig.Region, instance.launcher.fireProxDeploymentStageName)) 687 | } 688 | 689 | // Creates a new FireProx instance with the same characteristics as the given one 690 | func (instance *APIGateway) Renew() (*APIGateway, error) { 691 | return instance.launcher.CreateAPIGateway(context.Background(), instance.awsConfig, instance.AllRegisteredURLs, instance.Title) 692 | } 693 | 694 | // Checks if the given baseURL is already handled by this instance 695 | func (instance *APIGateway) DoesTargetURL(url1 *url.URL) bool { 696 | baseURL := utils.GetBaseURL(url1) 697 | 698 | for _, urlRegistered := range instance.AllRegisteredURLs { 699 | if utils.GetBaseURL(urlRegistered) == baseURL { 700 | return true 701 | } 702 | } 703 | 704 | return false 705 | } 706 | 707 | // Returns the API Gateway endpoint (with the right url path) to reach the given URL through AWS API Gateways 708 | func (instance APIGateway) GetURLForReachingGivenURL(url1 *url.URL) (*url.URL, error) { 709 | fireProxURLId, fireProxURLIdErr := getAPIGatewayURLIdentifierForReachingGivenURL(url1) 710 | 711 | if fireProxURLIdErr != nil { 712 | return nil, fireProxURLIdErr 713 | } 714 | 715 | path := utils.GetPathFromURL(url1) 716 | 717 | fireProxStageURL, fireProxStageURLErr := instance.GetAPIGatewayStageURL() 718 | 719 | if fireProxStageURLErr != nil { 720 | return nil, fireProxStageURLErr 721 | } 722 | 723 | return url.Parse(fmt.Sprintf("%s%s%s", fireProxStageURL, fireProxURLId, path)) 724 | } 725 | 726 | // Adds the new baseURL to this instance and redeploy the REST API 727 | func (instance *APIGateway) AddNewURL(ctx context.Context, url1 *url.URL) error { 728 | if !instance.CanStillIncrease() { 729 | return errors.New("the maximum of resource per API Gateway instance has been reached for this instance") 730 | } 731 | 732 | // It checks if the url has not already been added (to avoid race conditions) 733 | for _, registeredURL := range instance.AllRegisteredURLs { 734 | if utils.CompareBaseURLs(registeredURL, url1) { 735 | return nil 736 | } 737 | } 738 | 739 | for instance.Updating { 740 | time.Sleep(200 * time.Millisecond) //nolint:gomnd 741 | } 742 | 743 | instance.Updating = true 744 | 745 | client := apigateway.NewFromConfig(*instance.awsConfig) 746 | 747 | // Adds the base URL 748 | instance.AllRegisteredURLs = append(instance.AllRegisteredURLs, url1) 749 | 750 | // Refreshes the OpenAPI specification 751 | _, restAPIErr := client.PutRestApi(ctx, &apigateway.PutRestApiInput{ 752 | Body: instance.generateOpenAPISpecification(), 753 | Parameters: map[string]string{ 754 | "endpointConfigurationTypes": "REGIONAL", 755 | }, 756 | RestApiId: &instance.RestAPIID, 757 | Mode: types.PutModeOverwrite, 758 | }, apigateway.WithAPIOptions()) 759 | 760 | if restAPIErr != nil { 761 | instance.AllRegisteredURLs = utils.DeleteElementFromSlice(instance.AllRegisteredURLs, url1) 762 | 763 | instance.Updating = false 764 | 765 | return restAPIErr 766 | } 767 | 768 | deploymentDescription := instance.launcher.fireProxDeploymentDescription 769 | deploymentStageDescription := instance.launcher.fireProxDeploymentStageDescription 770 | deploymentStageName := instance.launcher.fireProxDeploymentStageName 771 | 772 | // Redeploys the API Gateway 773 | _, createDeploymentErr := client.CreateDeployment(ctx, &apigateway.CreateDeploymentInput{ 774 | Description: &deploymentDescription, 775 | StageDescription: &deploymentStageDescription, 776 | StageName: &deploymentStageName, 777 | RestApiId: &instance.RestAPIID, 778 | }, apigateway.WithAPIOptions()) 779 | 780 | if createDeploymentErr != nil { 781 | instance.AllRegisteredURLs = utils.DeleteElementFromSlice(instance.AllRegisteredURLs, url1) 782 | 783 | instance.Updating = false 784 | 785 | return createDeploymentErr 786 | } 787 | 788 | reachURL, reachURLErr := instance.GetURLForReachingGivenURL(url1) // URL for reaching the added baseURL through the FireProx instance 789 | 790 | if reachURLErr != nil { 791 | instance.Updating = false 792 | 793 | return reachURLErr 794 | } 795 | 796 | // Checks if the deployment has been done and propagated (checks if the added URL is reachable through the gateway) 797 | // It sends requests (for a maximum duration of maxDuration seconds) to the corresponding API Gateway URL and checks if it redirects to the targeted server 798 | startTime := time.Now() 799 | maxDuration := 15 * time.Second //nolint:revive,gomnd 800 | 801 | for time.Since(startTime) < maxDuration { 802 | time.Sleep(250 * time.Millisecond) //nolint:gomnd 803 | 804 | response, responseErr := http.Get(reachURL.String()) 805 | 806 | if responseErr != nil { 807 | continue 808 | } 809 | 810 | body, bodyErr := io.ReadAll(response.Body) 811 | 812 | closeErr := response.Body.Close() 813 | 814 | if closeErr != nil { 815 | continue 816 | } 817 | 818 | if bodyErr != nil { 819 | continue 820 | } 821 | 822 | if !strings.Contains(string(body), `{"message":"Missing Authentication Token"}`) { // If it is propagated, it stops the loop 823 | break 824 | } 825 | } 826 | 827 | // Additional waiting time for avoiding "Missing Authentication Token" (403) responses (not fully propagated instances) 828 | time.Sleep(3000 * time.Millisecond) //nolint:gomnd 829 | 830 | instance.Updating = false 831 | 832 | return nil 833 | } 834 | 835 | // Checks if new URLs can be added 836 | func (instance *APIGateway) CanStillIncrease() bool { 837 | return len(instance.AllRegisteredURLs) < utils.MaxResourcePerFireProxInstance 838 | } 839 | 840 | // Corresponds to the identifier for the API Gateway URL to redirect to the right target 841 | // Ex: https_google.fr:443 842 | func getAPIGatewayURLIdentifierForReachingGivenURL(urlTo *url.URL) (string, error) { 843 | baseURL := utils.GetBaseURL(urlTo) 844 | 845 | baseURLParsed, baseURLParsedErr := url.Parse(baseURL) // The URL is parsed again in order to add the port (if not added by default) 846 | 847 | if baseURLParsedErr != nil { 848 | return "", baseURLParsedErr 849 | } 850 | 851 | return fmt.Sprintf("%s_%s", baseURLParsed.Scheme, baseURLParsed.Host), baseURLParsedErr 852 | } 853 | --------------------------------------------------------------------------------