From 72beef10c9a51f4f3de9aa7fef47b1f4b0c7ddfe Mon Sep 17 00:00:00 2001 From: Vladislav Yarmak Date: Sun, 14 Mar 2021 22:38:55 +0200 Subject: [PATCH] feat: Hola API communication failover --- credservice.go | 55 ++++++++------ csrand.go | 23 ++++++ holaapi.go | 198 +++++++++++++++++++++++++++++++++++++++++++++---- utils.go | 45 +++++++++-- 4 files changed, 277 insertions(+), 44 deletions(-) create mode 100644 csrand.go diff --git a/credservice.go b/credservice.go index c534df3..30ab96f 100644 --- a/credservice.go +++ b/credservice.go @@ -2,12 +2,12 @@ package main import ( "context" + "net/http" "sync" "time" ) const DEFAULT_LIST_LIMIT = 3 -const API_CALL_ATTEMPTS = 3 func CredService(interval, timeout time.Duration, country string, @@ -24,15 +24,21 @@ func CredService(interval, timeout time.Duration, return } - for i := 0; i < API_CALL_ATTEMPTS; i++ { - ctx, _ := context.WithTimeout(context.Background(), timeout) - tunnels, user_uuid, err = Tunnels(ctx, country, proxytype, DEFAULT_LIST_LIMIT) - if err == nil { - break + tx_res, tx_err := EnsureTransaction(context.Background(), timeout, func(ctx context.Context, client *http.Client) bool { + tunnels, user_uuid, err = Tunnels(ctx, client, country, proxytype, DEFAULT_LIST_LIMIT) + if err != nil { + logger.Error("Configuration bootstrap error: %v. Retrying with a fallback mechanisms...", err) + return false } + return true + }) + if tx_err != nil { + logger.Critical("Transaction recovery mechanism failure: %v", tx_err) + err = tx_err + return } - if err != nil { - logger.Critical("Configuration bootstrap failed: %v", err) + if !tx_res { + logger.Critical("All attempts failed.") return } auth_header = basic_auth_header(LOGIN_PREFIX+user_uuid, @@ -48,23 +54,28 @@ func CredService(interval, timeout time.Duration, for { <-ticker.C logger.Info("Rotating credentials...") - for i := 0; i < API_CALL_ATTEMPTS; i++ { - ctx, _ := context.WithTimeout(context.Background(), timeout) - tuns, user_uuid, err = Tunnels(ctx, country, proxytype, DEFAULT_LIST_LIMIT) - if err == nil { - break + tx_res, tx_err := EnsureTransaction(context.Background(), timeout, func(ctx context.Context, client *http.Client) bool { + tuns, user_uuid, err = Tunnels(ctx, client, country, proxytype, DEFAULT_LIST_LIMIT) + if err != nil { + logger.Error("Credential rotation error: %v. Retrying with a fallback mechanisms...", err) + return false } + return true + }) + if tx_err != nil { + logger.Critical("Transaction recovery mechanism failure: %v", tx_err) + err = tx_err + continue } - if err != nil { - logger.Error("Credential rotation failed after %d attempts. Error: %v", - API_CALL_ATTEMPTS, err) - } else { - (&mux).Lock() - auth_header = basic_auth_header(LOGIN_PREFIX+user_uuid, - tuns.AgentKey) - (&mux).Unlock() - logger.Info("Credentials rotated successfully.") + if !tx_res { + logger.Critical("All rotation attempts failed.") + continue } + (&mux).Lock() + auth_header = basic_auth_header(LOGIN_PREFIX+user_uuid, + tuns.AgentKey) + (&mux).Unlock() + logger.Info("Credentials rotated successfully.") } }() return diff --git a/csrand.go b/csrand.go new file mode 100644 index 0000000..4a5cea8 --- /dev/null +++ b/csrand.go @@ -0,0 +1,23 @@ +package main + +import ( + crand "crypto/rand" + "math/big" +) + +type secureRandomSource struct{} + +var RandomSource secureRandomSource + +var int63Limit = big.NewInt(0).Lsh(big.NewInt(1), 63) + +func (_ secureRandomSource) Seed(_ int64) { +} + +func (_ secureRandomSource) Int63() int64 { + randNum, err := crand.Int(crand.Reader, int63Limit) + if err != nil { + panic(err) + } + return randNum.Int64() +} diff --git a/holaapi.go b/holaapi.go index 5e997d9..9c317ad 100644 --- a/holaapi.go +++ b/holaapi.go @@ -3,17 +3,22 @@ package main import ( "bytes" "context" + "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" - "github.com/campoy/unique" - "github.com/google/uuid" "io/ioutil" "math/rand" + "net" "net/http" "net/url" "strconv" + "sync" + "time" + + "github.com/campoy/unique" + "github.com/google/uuid" ) const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36" @@ -25,6 +30,8 @@ const VPN_COUNTRIES_URL = CCGI_URL + "vpn_countries.json" const BG_INIT_URL = CCGI_URL + "background_init" const ZGETTUNNELS_URL = CCGI_URL + "zgettunnels" const LOGIN_PREFIX = "user-uuid-" +const FALLBACK_CONF_URL = "https://www.dropbox.com/s/jemizcvpmf2qb9v/cloud_failover.conf?dl=1" +const AGENT_SUFFIX = ".hola.org" var TemporaryBanError = errors.New("temporary ban detected") var PermanentBanError = errors.New("permanent ban detected") @@ -57,11 +64,63 @@ type ZGetTunnelsResponse struct { Ztun map[string][]string `json:"ztun"` } -func do_req(ctx context.Context, method, url string, query, data url.Values) ([]byte, error) { +type FallbackAgent struct { + Name string `json:"name"` + IP string `json:"ip"` + Port uint16 `json:"port"` +} + +type fallbackConfResponse struct { + Agents []FallbackAgent `json:"agents"` + UpdatedAt int64 `json:"updated_ts"` + TTL int64 `json:"ttl_ms"` +} + +type FallbackConfig struct { + Agents []FallbackAgent + UpdatedAt time.Time + TTL time.Duration +} + +func (c *FallbackConfig) UnmarshalJSON(data []byte) error { + r := fallbackConfResponse{} + err := json.Unmarshal(data, &r) + if err != nil { + return err + } + c.Agents = r.Agents + c.UpdatedAt = time.Unix(r.UpdatedAt/1000, (r.UpdatedAt%1000)*1000000) + c.TTL = time.Duration(r.TTL * 1000000) + return nil +} + +func (c *FallbackConfig) Expired() bool { + return time.Now().After(c.UpdatedAt.Add(c.TTL)) +} + +func (c *FallbackConfig) ShuffleAgents() { + rand.New(RandomSource).Shuffle(len(c.Agents), func(i, j int) { + c.Agents[i], c.Agents[j] = c.Agents[j], c.Agents[i] + }) +} + +func (c *FallbackConfig) ToProxies() []*url.URL { + res := make([]*url.URL, 0, len(c.Agents)) + for _, agent := range c.Agents { + url := &url.URL{ + Scheme: "https", + Host: net.JoinHostPort(agent.Name+AGENT_SUFFIX, + fmt.Sprintf("%d", agent.Port)), + } + res = append(res, url) + } + return res +} + +func do_req(ctx context.Context, client *http.Client, method, url string, query, data url.Values) ([]byte, error) { var ( - client http.Client - req *http.Request - err error + req *http.Request + err error ) if method == "" { method = "GET" @@ -101,10 +160,10 @@ func do_req(ctx context.Context, method, url string, query, data url.Values) ([] return body, nil } -func VPNCountries(ctx context.Context) (res CountryList, err error) { +func VPNCountries(ctx context.Context, client *http.Client) (res CountryList, err error) { params := make(url.Values) params.Add("browser", EXT_BROWSER) - data, err := do_req(ctx, "", VPN_COUNTRIES_URL, params, nil) + data, err := do_req(ctx, client, "", VPN_COUNTRIES_URL, params, nil) if err != nil { return nil, err } @@ -119,13 +178,13 @@ func VPNCountries(ctx context.Context) (res CountryList, err error) { return } -func background_init(ctx context.Context, user_uuid string) (res BgInitResponse, reterr error) { +func background_init(ctx context.Context, client *http.Client, user_uuid string) (res BgInitResponse, reterr error) { post_data := make(url.Values) post_data.Add("login", "1") post_data.Add("ver", EXT_VER) qs := make(url.Values) qs.Add("uuid", user_uuid) - resp, err := do_req(ctx, "POST", BG_INIT_URL, qs, post_data) + resp, err := do_req(ctx, client, "POST", BG_INIT_URL, qs, post_data) if err != nil { reterr = err return @@ -143,6 +202,7 @@ func background_init(ctx context.Context, user_uuid string) (res BgInitResponse, } func zgettunnels(ctx context.Context, + client *http.Client, user_uuid string, session_key int64, country string, @@ -163,14 +223,14 @@ func zgettunnels(ctx context.Context, params.Add("country", country) } params.Add("limit", strconv.FormatInt(int64(limit), 10)) - params.Add("ping_id", strconv.FormatFloat(rand.Float64(), 'f', -1, 64)) + params.Add("ping_id", strconv.FormatFloat(rand.New(RandomSource).Float64(), 'f', -1, 64)) params.Add("ext_ver", EXT_VER) params.Add("browser", EXT_BROWSER) params.Add("product", PRODUCT) params.Add("uuid", user_uuid) params.Add("session_key", strconv.FormatInt(session_key, 10)) params.Add("is_premium", "0") - data, err := do_req(ctx, "", ZGETTUNNELS_URL, params, nil) + data, err := do_req(ctx, client, "", ZGETTUNNELS_URL, params, nil) if err != nil { reterr = err return @@ -180,17 +240,127 @@ func zgettunnels(ctx context.Context, return } +func fetchFallbackConfig(ctx context.Context) (*FallbackConfig, error) { + confRaw, err := do_req(ctx, &http.Client{}, "", FALLBACK_CONF_URL, nil, nil) + if err != nil { + return nil, err + } + + l := len(confRaw) + if l < 4 { + return nil, errors.New("bad response length from fallback conf URL") + } + + buf := &bytes.Buffer{} + buf.Grow(l) + buf.Write(confRaw[l-3:]) + buf.Write(confRaw[:l-3]) + + b64dec := base64.NewDecoder(base64.RawStdEncoding, buf) + jdec := json.NewDecoder(b64dec) + fbc := &FallbackConfig{} + + err = jdec.Decode(fbc) + if err != nil { + return nil, err + } + + if fbc.Expired() { + return nil, errors.New("fetched expired fallback config") + } + + fbc.ShuffleAgents() + return fbc, nil +} + +var ( + fbcMux sync.Mutex + cachedFBC *FallbackConfig +) + +func GetFallbackProxies(ctx context.Context) ([]*url.URL, error) { + fbcMux.Lock() + defer fbcMux.Unlock() + + var ( + fbc *FallbackConfig + err error + ) + + if cachedFBC == nil || cachedFBC.Expired() { + fbc, err = fetchFallbackConfig(ctx) + if err != nil { + return nil, err + } + } else { + fbc = cachedFBC + } + + return fbc.ToProxies(), nil +} + func Tunnels(ctx context.Context, + client *http.Client, country string, proxy_type string, limit uint) (res *ZGetTunnelsResponse, user_uuid string, reterr error) { u := uuid.New() user_uuid = hex.EncodeToString(u[:]) - initres, err := background_init(ctx, user_uuid) + initres, err := background_init(ctx, client, user_uuid) if err != nil { reterr = err return } - res, reterr = zgettunnels(ctx, user_uuid, initres.Key, country, proxy_type, limit) + res, reterr = zgettunnels(ctx, client, user_uuid, initres.Key, country, proxy_type, limit) return } + +// Returns default http client with a proxy override +func httpClientWithProxy(proxy *url.URL) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxy), + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } +} + +func EnsureTransaction(baseCtx context.Context, txnTimeout time.Duration, txn func(context.Context, *http.Client) bool) (bool, error) { + client := httpClientWithProxy(nil) + defer client.CloseIdleConnections() + + ctx, cancel := context.WithTimeout(baseCtx, txnTimeout) + defer cancel() + + if txn(ctx, client) { + return true, nil + } + + // Fallback needed + proxies, err := GetFallbackProxies(baseCtx) + if err != nil { + return false, err + } + + for _, proxy := range proxies { + client = httpClientWithProxy(proxy) + defer client.CloseIdleConnections() + + ctx, cancel = context.WithTimeout(baseCtx, txnTimeout) + defer cancel() + + if txn(ctx, client) { + return true, nil + } + } + + return false, nil +} diff --git a/utils.go b/utils.go index c54a203..67f72ed 100644 --- a/utils.go +++ b/utils.go @@ -50,10 +50,24 @@ func proxy(ctx context.Context, left, right net.Conn) { } func print_countries(timeout time.Duration) int { - ctx, _ := context.WithTimeout(context.Background(), timeout) - countries, err := VPNCountries(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + var ( + countries CountryList + err error + ) + tx_res, tx_err := EnsureTransaction(context.Background(), timeout, func(ctx context.Context, client *http.Client) bool { + countries, err = VPNCountries(ctx, client) + if err != nil { + fmt.Fprintf(os.Stderr, "Transaction error: %v. Retrying with a fallback mechanisms...\n", err) + return false + } + return true + }) + if tx_err != nil { + fmt.Fprintf(os.Stderr, "Transaction recovery mechanism failure: %v.\n", tx_err) + return 4 + } + if !tx_res { + fmt.Fprintf(os.Stderr, "All attempts failed.") return 3 } for _, code := range countries { @@ -63,10 +77,25 @@ func print_countries(timeout time.Duration) int { } func print_proxies(country string, proxy_type string, limit uint, timeout time.Duration) int { - ctx, _ := context.WithTimeout(context.Background(), timeout) - tunnels, user_uuid, err := Tunnels(ctx, country, proxy_type, limit) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + var ( + tunnels *ZGetTunnelsResponse + user_uuid string + err error + ) + tx_res, tx_err := EnsureTransaction(context.Background(), timeout, func(ctx context.Context, client *http.Client) bool { + tunnels, user_uuid, err = Tunnels(ctx, client, country, proxy_type, limit) + if err != nil { + fmt.Fprintf(os.Stderr, "Transaction error: %v. Retrying with a fallback mechanisms...\n", err) + return false + } + return true + }) + if tx_err != nil { + fmt.Fprintf(os.Stderr, "Transaction recovery mechanism failure: %v.\n", tx_err) + return 4 + } + if !tx_res { + fmt.Fprintf(os.Stderr, "All attempts failed.") return 3 } wr := csv.NewWriter(os.Stdout)