package main import ( "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io/ioutil" "log" "math" "net/http" "os" "os/exec" "path" "path/filepath" "regexp" "strconv" "strings" "time" errors "git.sequentialread.com/forest/pkg-errors" "golang.org/x/crypto/scrypt" ) // https://en.wikipedia.org/wiki/Scrypt type ScryptParameters struct { CPUAndMemoryCost int `json:"N"` BlockSize int `json:"r"` Paralellization int `json:"p"` KeyLength int `json:"klen"` } type Challenge struct { ScryptParameters Preimage string `json:"i"` Difficulty string `json:"d"` DifficultyLevel int `json:"dl"` } var currentChallengesGeneration = map[string]int{} var challenges = map[string]map[string]int{} func main() { var err error batchSize := 1000 deprecateAfterBatches := 10 portNumber := 2370 scryptCPUAndMemoryCost := 4096 batchSizeEnv := os.ExpandEnv("$POW_BOT_DETERRENT_BATCH_SIZE") deprecateAfterBatchesEnv := os.ExpandEnv("$POW_BOT_DETERRENT_DEPRECATE_AFTER_BATCHES") portNumberEnv := os.ExpandEnv("$POW_BOT_DETERRENT_LISTEN_PORT") scryptCPUAndMemoryCostEnv := os.ExpandEnv("$POW_BOT_DETERRENT_SCRYPT_CPU_AND_MEMORY_COST") if batchSizeEnv != "" { batchSize, err = strconv.Atoi(batchSizeEnv) if err != nil { panic(errors.Wrapf(err, "can't start the app because the POW_BOT_DETERRENT_BATCH_SIZE '%s' can't be converted to an integer", batchSizeEnv)) } } if deprecateAfterBatchesEnv != "" { deprecateAfterBatches, err = strconv.Atoi(deprecateAfterBatchesEnv) if err != nil { panic(errors.Wrapf(err, "can't start the app because the POW_BOT_DETERRENT_DEPRECATE_AFTER_BATCHES '%s' can't be converted to an integer", deprecateAfterBatchesEnv)) } } if portNumberEnv != "" { portNumber, err = strconv.Atoi(portNumberEnv) if err != nil { panic(errors.Wrapf(err, "can't start the app because the POW_BOT_DETERRENT_LISTEN_PORT '%s' can't be converted to an integer", portNumberEnv)) } } if scryptCPUAndMemoryCostEnv != "" { scryptCPUAndMemoryCost, err = strconv.Atoi(scryptCPUAndMemoryCostEnv) if err != nil { panic(errors.Wrapf(err, "can't start the app because the POW_BOT_DETERRENT_SCRYPT_CPU_AND_MEMORY_COST '%s' can't be converted to an integer", scryptCPUAndMemoryCostEnv)) } } apiTokensFolder := locateAPITokensFolder() adminAPIToken := os.ExpandEnv("$POW_BOT_DETERRENT_ADMIN_API_TOKEN") if adminAPIToken == "" { panic(errors.New("can't start the app, the POW_BOT_DETERRENT_ADMIN_API_TOKEN environment variable is required")) } scryptParameters := ScryptParameters{ CPUAndMemoryCost: scryptCPUAndMemoryCost, BlockSize: 8, Paralellization: 1, KeyLength: 16, } requireMethod := func(method string) func(http.ResponseWriter, *http.Request) bool { return func(responseWriter http.ResponseWriter, request *http.Request) bool { if request.Method != method { responseWriter.Header().Set("Allow", method) http.Error(responseWriter, fmt.Sprintf("405 Method Not Allowed, try %s", method), http.StatusMethodNotAllowed) return true } return false } } requireAdmin := func(responseWriter http.ResponseWriter, request *http.Request) bool { if request.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", adminAPIToken) { http.Error(responseWriter, "401 Unauthorized", http.StatusUnauthorized) return true } return false } requireToken := func(responseWriter http.ResponseWriter, request *http.Request) bool { authorizationHeader := request.Header.Get("Authorization") if !strings.HasPrefix(authorizationHeader, "Bearer ") { http.Error(responseWriter, "401 Unauthorized: Authorization header is required and must start with 'Bearer '", http.StatusUnauthorized) return true } token := strings.TrimPrefix(authorizationHeader, "Bearer ") if token == "" { http.Error(responseWriter, "401 Unauthorized: Authorization Bearer token is required", http.StatusUnauthorized) return true } if !regexp.MustCompile("^[0-9a-f]{32}$").MatchString(token) { errorMsg := fmt.Sprintf("401 Unauthorized: Authorization Bearer token '%s' must be a 32 character hex string", token) http.Error(responseWriter, errorMsg, http.StatusUnauthorized) return true } fileInfos, err := ioutil.ReadDir(apiTokensFolder) if err != nil { log.Printf("failed to list the apiTokensFolder (%s): %v", apiTokensFolder, err) http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError) return true } foundToken := false for _, fileInfo := range fileInfos { if strings.HasPrefix(fileInfo.Name(), token) { foundToken = true break } } if !foundToken { errorMsg := fmt.Sprintf("401 Unauthorized: Authorization Bearer token '%s' was in the right format, but it was unrecognized", token) http.Error(responseWriter, errorMsg, http.StatusUnauthorized) return true } return false } myHTTPHandleFunc("/Tokens", requireMethod("GET"), requireAdmin, func(responseWriter http.ResponseWriter, request *http.Request) bool { fileInfos, err := ioutil.ReadDir(apiTokensFolder) if err != nil { log.Printf("failed to list the apiTokensFolder (%s): %v", apiTokensFolder, err) http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError) return true } output := []string{} for _, fileInfo := range fileInfos { filenameSplit := strings.Split(fileInfo.Name(), "_") if len(filenameSplit) == 2 { filepath := path.Join(apiTokensFolder, fileInfo.Name()) content, err := ioutil.ReadFile(filepath) if err != nil { log.Printf("failed to read the token file (%s): %v", filepath, err) http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError) return true } contentInt64, err := strconv.ParseInt(string(content), 10, 64) timestampString := time.Unix(contentInt64, 0).UTC().Format(time.RFC3339) output = append(output, fmt.Sprintf("%s,%s,%d,%s", filenameSplit[0], filenameSplit[1], contentInt64, timestampString)) } } responseWriter.Header().Set("Content-Type", "text/plain") responseWriter.Write([]byte(strings.Join(output, "\n"))) return true }) myHTTPHandleFunc("/Tokens/Create", requireMethod("POST"), requireAdmin, func(responseWriter http.ResponseWriter, request *http.Request) bool { name := request.URL.Query().Get("name") if name == "" { http.Error(responseWriter, "400 Bad Request: url param ?name= is required", http.StatusBadRequest) return true } // we use underscore as a syntax character in the filename, so we have to remove it from the user-inputted name name = strings.ReplaceAll(name, "_", "-") // let's also remove any sort of funky or path-related characters name = strings.ReplaceAll(name, "*", "") name = strings.ReplaceAll(name, "?", "") name = strings.ReplaceAll(name, "/", "-") name = strings.ReplaceAll(name, "\\", "-") name = strings.ReplaceAll(name, ".", "-") tokenBytes := make([]byte, 16) rand.Read(tokenBytes) ioutil.WriteFile( path.Join(apiTokensFolder, fmt.Sprintf("%x_%s", tokenBytes, name)), []byte(fmt.Sprintf("%d", time.Now().Unix())), 0644, ) fmt.Fprintf(responseWriter, "%x", tokenBytes) return true }) myHTTPHandleFunc("/Tokens/Revoke", requireMethod("POST"), requireAdmin, func(responseWriter http.ResponseWriter, request *http.Request) bool { token := request.URL.Query().Get("token") if token == "" { http.Error(responseWriter, "400 Bad Request: url param ?token= is required", http.StatusBadRequest) return true } if !regexp.MustCompile("^[0-9a-f]{32}$").MatchString(token) { errorMsg := fmt.Sprintf("400 Bad Request: url param ?token=%s must be a 32 character hex string", token) http.Error(responseWriter, errorMsg, http.StatusBadRequest) return true } fileInfos, err := ioutil.ReadDir(apiTokensFolder) if err != nil { log.Printf("failed to list the apiTokensFolder (%s): %v", apiTokensFolder, err) http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError) return true } for _, fileInfo := range fileInfos { if strings.HasPrefix(fileInfo.Name(), token) { os.Remove(path.Join(apiTokensFolder, fileInfo.Name())) } } responseWriter.Write([]byte("Revoked")) return true }) myHTTPHandleFunc("/GetChallenges", requireMethod("POST"), requireToken, func(responseWriter http.ResponseWriter, request *http.Request) bool { // requireToken already validated the API Token, so we can just do this: token := strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer ") if _, has := currentChallengesGeneration[token]; !has { currentChallengesGeneration[token] = 0 } if _, has := challenges[token]; !has { challenges[token] = map[string]int{} } currentChallengesGeneration[token]++ requestQuery := request.URL.Query() difficultyLevelString := requestQuery.Get("difficultyLevel") difficultyLevel, err := strconv.Atoi(difficultyLevelString) if err != nil { errorMessage := fmt.Sprintf( "400 url param ?difficultyLevel=%s value could not be converted to an integer", difficultyLevelString, ) http.Error(responseWriter, errorMessage, http.StatusBadRequest) return true } toReturn := make([]string, batchSize) for i := 0; i < batchSize; i++ { preimageBytes := make([]byte, 8) _, err := rand.Read(preimageBytes) if err != nil { log.Printf("read random bytes failed: %v", err) http.Error(responseWriter, "500 internal server error", http.StatusInternalServerError) return true } preimage := base64.StdEncoding.EncodeToString(preimageBytes) difficultyBytes := make([]byte, int(math.Ceil(float64(difficultyLevel)/float64(8)))) for j := 0; j < len(difficultyBytes); j++ { difficultyByte := byte(0) for k := 0; k < 8; k++ { currentBitIndex := (len(difficultyBytes) * 8) - (j*8 + k) if currentBitIndex > difficultyLevel { difficultyByte = difficultyByte | 1< challenge.Difficulty { errorMessage := fmt.Sprintf( "400 bad request: nonce given by url param ?nonce=%s did not result in a hash that meets the required difficulty", nonceHex, ) http.Error(responseWriter, errorMessage, http.StatusBadRequest) return true } responseWriter.WriteHeader(200) responseWriter.Write([]byte("OK")) return true }) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) log.Printf("💥 PoW! Bot Deterrent server listening on port %d", portNumber) err = http.ListenAndServe(fmt.Sprintf(":%d", portNumber), nil) // if got this far it means server crashed! panic(err) } func myHTTPHandleFunc(path string, stack ...func(http.ResponseWriter, *http.Request) bool) { http.HandleFunc(path, func(responseWriter http.ResponseWriter, request *http.Request) { for _, handler := range stack { if handler(responseWriter, request) { break } } }) } func locateAPITokensFolder() string { workingDirectory, err := os.Getwd() if err != nil { log.Fatalf("locateAPITokensFolder(): can't os.Getwd(): %v", err) } executableDirectory, err := getCurrentExecDir() if err != nil { log.Fatalf("locateAPITokensFolder(): can't getCurrentExecDir(): %v", err) } nextToExecutable := filepath.Join(executableDirectory, "PoW_Bot_Deterrent_API_Tokens") inWorkingDirectory := filepath.Join(workingDirectory, "PoW_Bot_Deterrent_API_Tokens") nextToExecutableStat, err := os.Stat(nextToExecutable) foundKeysNextToExecutable := err == nil && nextToExecutableStat.IsDir() inWorkingDirectoryStat, err := os.Stat(inWorkingDirectory) foundKeysInWorkingDirectory := err == nil && inWorkingDirectoryStat.IsDir() if foundKeysNextToExecutable && foundKeysInWorkingDirectory && workingDirectory != executableDirectory { log.Fatalf(`locateAPITokensFolder(): Something went wrong with your installation, I found two PoW_Bot_Deterrent_API_Tokens folders and I'm not sure which one to use. One of them is located at %s and the other is at %s`, inWorkingDirectory, nextToExecutable) } if foundKeysInWorkingDirectory { return inWorkingDirectory } else if foundKeysNextToExecutable { return nextToExecutable } log.Fatalf(`locateAPITokensFolder(): I didn't find a PoW_Bot_Deterrent_API_Tokens folder in the current working directory (in %s) or next to the executable (in %s)`, workingDirectory, executableDirectory) return "" } func getCurrentExecDir() (dir string, err error) { path, err := exec.LookPath(os.Args[0]) if err != nil { fmt.Printf("exec.LookPath(%s) returned %s\n", os.Args[0], err) return "", err } absPath, err := filepath.Abs(path) if err != nil { fmt.Printf("filepath.Abs(%s) returned %s\n", path, err) return "", err } dir = filepath.Dir(absPath) return dir, nil }