aboutsummaryrefslogblamecommitdiffstats
path: root/arbiter/arbiter.go
blob: 6145a45dec129c5eafa5f924f7cf36736e7d5cc6 (plain) (tree)
1
2
3
4
5
6
7
8
9




                    
                       

                
            
                  
                 

                   
                 
              








                                      


                                                                     


       







                                                                    

 

                                         
                                            











                                                           







                                                  




                                                                
                                                             
 
                         
                       
                                











                             
                                      
                                     
                                        
                                                             
         





                                                               
 
                     

 
                                                                                       






                                                                                             

 
                           
                            


                                         

                                       

                       

                        


                                                                      
 
                                    



                                                                              
                                                                    
 
                                    
 


                      
                                   
                       
                                                                           
                                                                    
 
                                           
 
                      


                               
                                          
                       
                                                                           
                                                                    
 
                                           


                                                
 

                                             
                                                                           
                                                                    
 
                                           

         








                                                                           


                                    










                                                                           


                                                             
                         
                            
                    

                                         

                                       

                       

                        

                                                    
 
                                    



                                                                              
                                                                    
 
                                    
 

                      

                                   
                       
                                                                           
                                                                    
 
                                           
 
                      


                               
                                          
                       
                                                                           
                                                                    
 

                                    

                                                           
 

                                                            
                                                                           
                                                                    
 
                                           

         
                                                                                       
                       
                                                                           
                                                                    
 
                                           
         


                                    
                                                                   


                                                             

                                                          

                                       

                                                                              
 
                            

                       
 
                       
 







                                        
                                                           




                                                  










                                                                                        


                              
                                 


                                                
                                                   






                                    
                                            

         



















                                                   
 
 
                                                             
                                       

                                                                              


                                                          


                            
 




                                        
                        
                                                                  




                                                  







                                                                                        
                                                                      
                        


                              
                                 


                                                
                                            






                                    
                                            

         










                                                   

                                           






                                    

 




                                                        



                                                 

                                             
         
 
                            
                                                


                                                                  

                                  
 
                                      
                                                                        
         


                                            


                                                        

                                                                                                
                            

                                                       
                        
                                                                    
                 
 
                                                                                
                                            






                                      
 

                                                                              




                                                 


                                                         

                                      













                                                                                                                 
                    
 







                                             

                                      

                                                                           
         
 
                                                                                
 
package main

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"errors"
	"flag"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"time"

	"github.com/go-redis/redis/v8"
	"github.com/gorilla/mux"
	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
)

var (
	errBadLogic          = errors.New("we should not be here")
	errUnexpectedParam   = errors.New("got unexpected parameter")
	errUnknownDeployment = errors.New("unknown deployment kind")
)

const (
	serverDeploymentType    = "SERVER_DEPLOYMENT_TYPE"
	coingeckoAPIURLv3       = "https://api.coingecko.com/api/v3"
	coincapAPIURLv2         = "https://api.coincap.io/v2"
	getTimeout              = 5
	httpClientTimeout       = 5
	serverTLSReadTimeout    = 15
	serverTLSWriteTimeout   = 15
	defaultGracefulShutdown = 15
)

// https://docs.coincap.io/
type CoinCapAssetGetResponseData struct {
	ID                string `json:"id"`
	Rank              string `json:"rank"`
	Symbol            string `json:"symbol"`
	Name              string `json:"name"`
	Supply            string `json:"supply"`
	MaxSupply         string `json:"maxSupply"`
	MarketCapUsd      string `json:"marketCapUsd"`
	VolumeUsd24Hr     string `json:"volumeUsd24Hr"`
	PriceUsd          string `json:"priceUsd"`
	ChangePercent24Hr string `json:"changePercent24Hr"`
	Vwap24Hr          string `json:"vwap24Hr"`
}

type priceResponseData struct {
	Name         string  `json:"name"`
	Price        float64 `json:"price"`
	Unit         string  `json:"unit"`
	Err          string  `json:"err"`
	IsSuccessful bool    `json:"isSuccessful"`
}

type CoinCapAssetGetResponse struct {
	Data      CoinCapAssetGetResponseData `json:"data"`
	TimeStamp int64                       `json:"timestamp"`
}

type HTTPHandlerFunc func(http.ResponseWriter, *http.Request)

type HTTPHandler struct {
	name     string
	function HTTPHandlerFunc
}

type priceChanStruct struct {
	name  string
	price float64
}

type errorChanStruct struct {
	hasError bool
	err      error
}

func GetProxiedClient() *http.Client {
	transport := &http.Transport{
		DisableKeepAlives: true,
		Proxy:             http.ProxyFromEnvironment,
	}
	client := &http.Client{
		Transport:     transport,
		Timeout:       httpClientTimeout * time.Second,
		CheckRedirect: nil,
		Jar:           nil,
	}

	return client
}

// OWASP: https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
func addSecureHeaders(writer *http.ResponseWriter) {
	(*writer).Header().Set("Cache-Control", "no-store")
	(*writer).Header().Set("Content-Security-Policy", "default-src https;")
	(*writer).Header().Set("Strict-Transport-Security", "max-age=63072000;")
	(*writer).Header().Set("X-Content-Type-Options", "nosniff")
	(*writer).Header().Set("X-Frame-Options", "DENY")
	(*writer).Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
}

func getPriceFromCoinGecko(
	ctx context.Context,
	name, unit string,
	wg *sync.WaitGroup,
	priceChan chan<- priceChanStruct,
	errChan chan<- errorChanStruct,
) {
	defer wg.Done()

	priceFloat := 0.

	params := "/simple/price?ids=" + url.QueryEscape(name) + "&" +
		"vs_currencies=" + url.QueryEscape(unit)
	path := coingeckoAPIURLv3 + params

	client := GetProxiedClient()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err)

		return
	}

	resp, err := client.Do(req)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err).Send()

		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err).Send()
	}

	jsonBody := make(map[string]interface{})

	err = json.Unmarshal(body, &jsonBody)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err).Send()
	}

	price, isOk := jsonBody[name].(map[string]interface{})
	if !isOk {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err)

		return
	}

	log.Info().Msg(string(body))

	priceFloat, isOk = price[unit].(float64)
	if !isOk {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err)

		return
	}

	priceChan <- priceChanStruct{name: name, price: priceFloat}
	errChan <- errorChanStruct{hasError: false, err: nil}
}

func getPriceFromCoinCap(
	ctx context.Context,
	name string,
	wg *sync.WaitGroup,
	priceChan chan<- priceChanStruct,
	errChan chan<- errorChanStruct,
) {
	defer wg.Done()

	priceFloat := 0.

	params := "/assets/" + url.QueryEscape(name)
	path := coincapAPIURLv2 + params

	client := GetProxiedClient()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err)

		return
	}

	resp, err := client.Do(req)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err).Send()

		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err)
	}

	var coinCapAssetGetResponse CoinCapAssetGetResponse

	err = json.Unmarshal(body, &coinCapAssetGetResponse)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err).Send()
	}

	priceFloat, err = strconv.ParseFloat(coinCapAssetGetResponse.Data.PriceUsd, 64)
	if err != nil {
		priceChan <- priceChanStruct{name: name, price: priceFloat}
		errChan <- errorChanStruct{hasError: true, err: err}

		log.Error().Err(err).Send()
	}

	log.Info().Msg(string(body))

	priceChan <- priceChanStruct{name: name, price: priceFloat}
	errChan <- errorChanStruct{hasError: false, err: nil}
}

func arbHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Add("Content-Type", "application/json")

	if r.Method != http.MethodGet {
		http.Error(w, "Method is not supported.", http.StatusNotFound)
	}

	addSecureHeaders(&w)

	var name string

	var unit string

	params := r.URL.Query()
	for key, value := range params {
		switch key {
		case "name":
			name = value[0]
		case "unit":
			unit = value[0]
		default:
			log.Error().Err(errUnexpectedParam)
		}
	}

	priceChan := make(chan priceChanStruct, 1)
	errChan := make(chan errorChanStruct, 1)

	var waitGroup sync.WaitGroup

	ctx, cancel := context.WithTimeout(context.Background(), getTimeout*time.Second)
	defer cancel()

	waitGroup.Add(1)

	//nolint:contextcheck
	getPriceFromCoinGecko(ctx, name, unit, &waitGroup, priceChan, errChan)
	waitGroup.Wait()

	select {
	case err := <-errChan:
		if err.hasError {
			log.Error().Err(err.err)
		}
	default:
		log.Error().Err(errBadLogic).Send()
	}

	var price priceChanStruct
	select {
	case priceCh := <-priceChan:
		price = priceCh
	default:
		log.Error().Err(errBadLogic)
	}

	responseData := priceResponseData{
		Name:         price.name,
		Price:        price.price,
		Unit:         "USD",
		Err:          "",
		IsSuccessful: true,
	}

	jsonResp, err := json.Marshal(responseData)
	if err != nil {
		cancel()
		//nolint:gocritic
		log.Fatal().Err(err)
	}

	_, err = w.Write(jsonResp)
	if err != nil {
		cancel()
		log.Fatal().Err(err)
	}
}

func coincapHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "Method is not supported.", http.StatusNotFound)
	}

	w.Header().Add("Content-Type", "application/json")

	addSecureHeaders(&w)

	var name string

	params := r.URL.Query()
	for key, value := range params {
		switch key {
		case "name":
			name = value[0]
		default:
			log.Error().Err(errUnexpectedParam).Send()
		}
	}

	priceChan := make(chan priceChanStruct, 1)
	errChan := make(chan errorChanStruct, 1)

	var waitGroup sync.WaitGroup

	waitGroup.Add(1)

	ctx, cancel := context.WithTimeout(context.Background(), getTimeout*time.Second)
	defer cancel()

	getPriceFromCoinCap(ctx, name, &waitGroup, priceChan, errChan)
	waitGroup.Wait()

	select {
	case err := <-errChan:
		if err.hasError {
			log.Error().Err(err.err)
		}
	default:
		log.Error().Err(errBadLogic)
	}

	var price priceChanStruct
	select {
	case priceCh := <-priceChan:
		price = priceCh
	default:
		log.Error().Err(errBadLogic)
	}

	responseData := priceResponseData{
		Name:         price.name,
		Price:        price.price,
		Unit:         "USD",
		Err:          "",
		IsSuccessful: true,
	}

	jsonResp, err := json.Marshal(responseData)
	if err != nil {
		cancel()

		log.Fatal().Err(err).Send()
	}

	_, err = w.Write(jsonResp)
	if err != nil {
		cancel()
		log.Fatal().Err(err)
	}
}

func setupLogging() {
	zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
}

func startServer(gracefulWait time.Duration,
	handlers []HTTPHandler,
	serverDeploymentType string, port string,
) {
	route := mux.NewRouter()
	cfg := &tls.Config{
		MinVersion: tls.VersionTLS13,
	}

	srv := &http.Server{
		Addr:         "0.0.0.0:" + port,
		WriteTimeout: time.Second * serverTLSWriteTimeout,
		ReadTimeout:  time.Second * serverTLSReadTimeout,
		Handler:      route,
		TLSConfig:    cfg,
	}

	for i := range len(handlers) {
		route.HandleFunc(handlers[i].name, handlers[i].function)
	}

	go func() {
		var certPath, keyPath string

		switch os.Getenv(serverDeploymentType) {
		case "deployment":
			certPath = "/etc/letsencrypt/live/api.terminaldweller.com/fullchain.pem"
			keyPath = "/etc/letsencrypt/live/api.terminaldweller.com/privkey.pem"
		case "test":
			certPath = "/certs/server.cert"
			keyPath = "/certs/server.key"
		default:
			log.Error().Err(errUnknownDeployment).Send()
		}

		if err := srv.ListenAndServeTLS(certPath, keyPath); err != nil {
			log.Error().Err(err)
		}
	}()

	c := make(chan os.Signal, 1)

	signal.Notify(c, os.Interrupt)
	<-c

	ctx, cancel := context.WithTimeout(context.Background(), gracefulWait)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Error().Err(err)
	}

	log.Info().Msg("gracefully shut down the server")
}

func main() {
	var gracefulWait time.Duration

	var rdb *redis.Client

	flag.DurationVar(
		&gracefulWait,
		"gracefulwait",
		time.Second*defaultGracefulShutdown,
		"the duration to wait during the graceful shutdown",
	)

	flagPort := flag.String("port", "8009", "determines the port the server will listen on")
	redisDB := flag.Int64("redisdb", 1, "determines the db number")
	redisAddress := flag.String("redisaddress", "redis:6379", "determines the address of the redis instance")
	redisPassword := flag.String("redispassword", "", "determines the password of the redis db")
	flag.Parse()

	rdb = redis.NewClient(&redis.Options{
		Addr:     *redisAddress,
		Password: *redisPassword,
		DB:       int(*redisDB),
	})
	defer rdb.Close()

	setupLogging()

	handlerFuncs := []HTTPHandler{
		{name: "/crypto/v1/arb/gecko", function: arbHandler},
		{name: "/crypto/v1/arb/coincap", function: coincapHandler},
	}

	startServer(gracefulWait, handlerFuncs, serverDeploymentType, *flagPort)
}