diff --git a/.gitignore b/.gitignore index 58bb2f3..9b09404 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ # vendor/ bin/ *.snap +opera-proxy diff --git a/README.md b/README.md index 7e9ccad..32da7a2 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ eu3.sec-tunnel.com,77.111.244.22,443 | list-countries | - | list available countries and exit | | list-proxies | - | output proxy list and exit | | proxy | String | sets base proxy to use for all dial-outs. Format: `://[login:password@]host[:port]` Examples: `http://user:password@192.168.1.1:3128`, `socks5://10.0.0.1:1080` | +| refresh | Duration | login refresh interval (default 4h0m0s) | | timeout | Duration | timeout for network operations (default 10s) | | verbosity | Number | logging verbosity (10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical) (default 20) | | version | - | show program version and exit | diff --git a/fixed.go b/fixed.go index baff3ff..d5b5631 100644 --- a/fixed.go +++ b/fixed.go @@ -7,13 +7,13 @@ import ( type FixedDialer struct { fixedAddress string - next ContextDialer + next ContextDialer } func NewFixedDialer(address string, next ContextDialer) *FixedDialer { return &FixedDialer{ fixedAddress: address, - next: next, + next: next, } } diff --git a/go.mod b/go.mod index dc9c0d1..935a7f9 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.16 require ( github.com/AdguardTeam/dnsproxy v0.36.0 - github.com/Snawoot/go-http-digest-auth-client v1.0.0 + github.com/Snawoot/go-http-digest-auth-client v1.1.3 github.com/miekg/dns v1.1.35 golang.org/x/net v0.0.0-20210324205630-d1beb07c2056 ) diff --git a/go.sum b/go.sum index 37a6a4b..abada93 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/AdguardTeam/golibs v0.4.2/go.mod h1:skKsDKIBB7kkFflLJBpfGX+G8QFTx0WKU github.com/AdguardTeam/golibs v0.4.4 h1:cM9UySQiYFW79zo5XRwnaIWVzfW4eNXmZktMrWbthpw= github.com/AdguardTeam/golibs v0.4.4/go.mod h1:skKsDKIBB7kkFflLJBpfGX+G8QFTx0WKUzB6TIgtUj4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Snawoot/go-http-digest-auth-client v1.0.0 h1:1xNR4muFtT+PjP1Z2QAZG0fooszF8nWuYv6QlTKgi1E= -github.com/Snawoot/go-http-digest-auth-client v1.0.0/go.mod h1:WiwNiPXTRGyjTGpBtSQJlM2wDPRRPpFGhMkMWpV4uqg= +github.com/Snawoot/go-http-digest-auth-client v1.1.3 h1:Xd/SNBuIUJqotzmxRpbXovBJxmlVZOT19IZZdMdrJ0Q= +github.com/Snawoot/go-http-digest-auth-client v1.1.3/go.mod h1:WiwNiPXTRGyjTGpBtSQJlM2wDPRRPpFGhMkMWpV4uqg= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 h1:52m0LGchQBBVqJRyYYufQuIbVqRawmubW3OFGqK1ekw= diff --git a/main.go b/main.go index 84d4219..020b775 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ import ( ) const ( - API_DOMAIN = "api.sec-tunnel.com" + API_DOMAIN = "api.sec-tunnel.com" PROXY_SUFFIX = "sec-tunnel.com" ) @@ -54,6 +54,7 @@ type CLIArgs struct { apiPassword string apiAddress string bootstrapDNS string + refresh time.Duration } func parse_args() CLIArgs { @@ -74,8 +75,9 @@ func parse_args() CLIArgs { flag.StringVar(&args.apiAddress, "api-address", "", fmt.Sprintf("override IP address of %s", API_DOMAIN)) flag.StringVar(&args.bootstrapDNS, "bootstrap-dns", "", "DNS/DoH/DoT/DoQ resolver for initial discovering of SurfEasy API address. "+ - "See https://github.com/ameshkov/dnslookup/ for upstream DNS URL format. "+ - "Examples: https://1.1.1.1/dns-query, quic://dns.adguard.com") + "See https://github.com/ameshkov/dnslookup/ for upstream DNS URL format. "+ + "Examples: https://1.1.1.1/dns-query, quic://dns.adguard.com") + flag.DurationVar(&args.refresh, "refresh", 4*time.Hour, "login refresh interval") flag.Parse() if args.country == "" { arg_fail("Country can't be empty string.") @@ -227,10 +229,31 @@ func run() int { return 13 } + runTicker(context.Background(), args.refresh, func(ctx context.Context) { + mainLogger.Info("Refreshing login...") + reqCtx, cl := context.WithTimeout(ctx, args.timeout) + defer cl() + err := seclient.Login(reqCtx) + if err != nil { + mainLogger.Error("Login refresh failed: %v", err) + return + } + mainLogger.Info("Login refreshed.") + + mainLogger.Info("Refreshing device password...") + reqCtx, cl = context.WithTimeout(ctx, args.timeout) + defer cl() + err = seclient.DeviceGeneratePassword(reqCtx) + if err != nil { + mainLogger.Error("Device password refresh failed: %v", err) + return + } + mainLogger.Info("Device password refreshed.") + }) + endpoint := ips[0] - authHdr := basic_auth_header(seclient.GetProxyCredentials()) auth := func() string { - return authHdr + return basic_auth_header(seclient.GetProxyCredentials()) } handlerDialer := NewProxyDialer(endpoint.NetAddr(), fmt.Sprintf("%s0.%s", args.country, PROXY_SUFFIX), auth, dialer) diff --git a/seclient/jar.go b/seclient/jar.go new file mode 100644 index 0000000..106504c --- /dev/null +++ b/seclient/jar.go @@ -0,0 +1,53 @@ +package seclient + +import ( + "net/http" + "net/http/cookiejar" + "net/url" + "sync" + + "golang.org/x/net/publicsuffix" +) + +type StdJar struct { + jar *cookiejar.Jar + mux sync.RWMutex +} + +func NewStdJar() (*StdJar, error) { + var jar StdJar + + err := jar.Reset() + if err != nil { + return nil, err + } + + return &jar, nil +} + +func (j *StdJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mux.RLock() + j.jar.SetCookies(u, cookies) + j.mux.RUnlock() +} + +func (j *StdJar) Cookies(u *url.URL) []*http.Cookie { + j.mux.RLock() + c := j.jar.Cookies(u) + j.mux.RUnlock() + return c +} + +func (j *StdJar) Reset() error { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + return err + } + + j.mux.Lock() + j.jar = jar + j.mux.Unlock() + return nil +} diff --git a/seclient/messages.go b/seclient/messages.go index 48d2331..36fa3a1 100644 --- a/seclient/messages.go +++ b/seclient/messages.go @@ -62,6 +62,15 @@ type SERegisterDeviceResponse struct { Status SEStatusPair `json:"return_code"` } +type SEDeviceGeneratePasswordData struct { + DevicePassword string `json:"device_password"` +} + +type SEDeviceGeneratePasswordResponse struct { + Data SEDeviceGeneratePasswordData `json:"data"` + Status SEStatusPair `json:"return_code"` +} + type SEGeoEntry struct { Country string `json:"country,omitempty"` CountryCode string `json:"country_code"` @@ -94,3 +103,5 @@ type SEDiscoverResponse struct { } `json:"data"` Status SEStatusPair `json:"return_code"` } + +type SESubscriberLoginResponse SERegisterSubscriberResponse diff --git a/seclient/seclient.go b/seclient/seclient.go index 929d9fa..999fc68 100644 --- a/seclient/seclient.go +++ b/seclient/seclient.go @@ -8,12 +8,11 @@ import ( "io/ioutil" "math/rand" "net/http" - "net/http/cookiejar" "net/url" "strings" + "sync" dac "github.com/Snawoot/go-http-digest-auth-client" - "golang.org/x/net/publicsuffix" ) const ( @@ -24,19 +23,21 @@ const ( ) type SEEndpoints struct { - RegisterSubscriber string - SubscriberLogin string - RegisterDevice string - GeoList string - Discover string + RegisterSubscriber string + SubscriberLogin string + RegisterDevice string + DeviceGeneratePassword string + GeoList string + Discover string } var DefaultSEEndpoints = SEEndpoints{ - RegisterSubscriber: "https://api.sec-tunnel.com/v4/register_subscriber", - SubscriberLogin: "https://api.sec-tunnel.com/v4/subscriber_login", - RegisterDevice: "https://api.sec-tunnel.com/v4/register_device", - GeoList: "https://api.sec-tunnel.com/v4/geo_list", - Discover: "https://api.sec-tunnel.com/v4/discover", + RegisterSubscriber: "https://api.sec-tunnel.com/v4/register_subscriber", + SubscriberLogin: "https://api.sec-tunnel.com/v4/subscriber_login", + RegisterDevice: "https://api.sec-tunnel.com/v4/register_device", + DeviceGeneratePassword: "https://api.sec-tunnel.com/v4/device_generate_password", + GeoList: "https://api.sec-tunnel.com/v4/geo_list", + Discover: "https://api.sec-tunnel.com/v4/discover", } type SESettings struct { @@ -58,7 +59,7 @@ var DefaultSESettings = SESettings{ } type SEClient struct { - HttpClient *http.Client + httpClient *http.Client Settings SESettings SubscriberEmail string SubscriberPassword string @@ -66,9 +67,12 @@ type SEClient struct { AssignedDeviceID string AssignedDeviceIDHash string DevicePassword string + Mux sync.Mutex rng *rand.Rand } +type StrKV map[string]string + // Instantiates SurfEasy client with default settings and given API keys. // Optional `transport` parameter allows to override HTTP transport used // for HTTP calls @@ -77,13 +81,6 @@ func NewSEClient(apiUsername, apiSecret string, transport http.RoundTripper) (*S transport = http.DefaultTransport } - jar, err := cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, - }) - if err != nil { - return nil, err - } - rng := rand.New(RandomSource) device_id, err := randomCapitalHexString(rng, DEVICE_ID_BYTES) @@ -91,18 +88,38 @@ func NewSEClient(apiUsername, apiSecret string, transport http.RoundTripper) (*S return nil, err } - return &SEClient{ - HttpClient: &http.Client{ - Transport: dac.NewDigestTransport(apiUsername, apiSecret, transport), + jar, err := NewStdJar() + if err != nil { + return nil, err + } + + res := &SEClient{ + httpClient: &http.Client{ Jar: jar, + Transport: dac.NewDigestTransport(apiUsername, apiSecret, transport), }, Settings: DefaultSESettings, rng: rng, DeviceID: device_id, - }, nil + } + + return res, nil +} + +func (c *SEClient) ResetCookies() error { + c.Mux.Lock() + defer c.Mux.Unlock() + return c.resetCookies() +} + +func (c *SEClient) resetCookies() error { + return (c.httpClient.Jar.(*StdJar)).Reset() } func (c *SEClient) AnonRegister(ctx context.Context) error { + c.Mux.Lock() + defer c.Mux.Unlock() + localPart, err := randomEmailLocalPart(c.rng) if err != nil { return err @@ -111,40 +128,26 @@ func (c *SEClient) AnonRegister(ctx context.Context) error { c.SubscriberEmail = fmt.Sprintf("%s@%s.best.vpn", localPart, c.Settings.ClientType) c.SubscriberPassword = capitalHexSHA1(c.SubscriberEmail) - return c.Register(ctx) + return c.register(ctx) } func (c *SEClient) Register(ctx context.Context) error { - registerInput := url.Values{ - "email": {c.SubscriberEmail}, - "password": {c.SubscriberPassword}, - } - req, err := http.NewRequestWithContext( - ctx, - "POST", - c.Settings.Endpoints.RegisterSubscriber, - strings.NewReader(registerInput.Encode()), - ) - if err != nil { - return err - } - c.populateRequest(req) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") + c.Mux.Lock() + defer c.Mux.Unlock() + return c.register(ctx) +} - resp, err := c.HttpClient.Do(req) +func (c *SEClient) register(ctx context.Context) error { + err := c.resetCookies() if err != nil { return err } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("bad http status: %s", resp.Status) - } - - decoder := json.NewDecoder(resp.Body) var regRes SERegisterSubscriberResponse - err = decoder.Decode(®Res) - cleanupBody(resp.Body) + err = c.rpcCall(ctx, c.Settings.Endpoints.RegisterSubscriber, StrKV{ + "email": c.SubscriberEmail, + "password": c.SubscriberPassword, + }, ®Res) if err != nil { return err } @@ -157,38 +160,15 @@ func (c *SEClient) Register(ctx context.Context) error { } func (c *SEClient) RegisterDevice(ctx context.Context) error { - registerDeviceInput := url.Values{ - "client_type": {c.Settings.ClientType}, - "device_hash": {c.DeviceID}, - "device_name": {c.Settings.DeviceName}, - } - req, err := http.NewRequestWithContext( - ctx, - "POST", - c.Settings.Endpoints.RegisterDevice, - strings.NewReader(registerDeviceInput.Encode()), - ) - if err != nil { - return err - } - c.populateRequest(req) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") + c.Mux.Lock() + defer c.Mux.Unlock() - resp, err := c.HttpClient.Do(req) - if err != nil { - return err - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("bad http status: %s", resp.Status) - } - - decoder := json.NewDecoder(resp.Body) var regRes SERegisterDeviceResponse - err = decoder.Decode(®Res) - cleanupBody(resp.Body) - + err := c.rpcCall(ctx, c.Settings.Endpoints.RegisterDevice, StrKV{ + "client_type": c.Settings.ClientType, + "device_hash": c.DeviceID, + "device_name": c.Settings.DeviceName, + }, ®Res) if err != nil { return err } @@ -205,36 +185,13 @@ func (c *SEClient) RegisterDevice(ctx context.Context) error { } func (c *SEClient) GeoList(ctx context.Context) ([]SEGeoEntry, error) { - geoListInput := url.Values{ - "device_id": {c.AssignedDeviceIDHash}, - } - req, err := http.NewRequestWithContext( - ctx, - "POST", - c.Settings.Endpoints.GeoList, - strings.NewReader(geoListInput.Encode()), - ) - if err != nil { - return nil, err - } - c.populateRequest(req) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") + c.Mux.Lock() + defer c.Mux.Unlock() - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, err - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("bad http status: %s", resp.Status) - } - - decoder := json.NewDecoder(resp.Body) var geoListRes SEGeoListResponse - err = decoder.Decode(&geoListRes) - cleanupBody(resp.Body) - + err := c.rpcCall(ctx, c.Settings.Endpoints.GeoList, StrKV{ + "device_id": c.AssignedDeviceIDHash, + }, &geoListRes) if err != nil { return nil, err } @@ -248,37 +205,14 @@ func (c *SEClient) GeoList(ctx context.Context) ([]SEGeoEntry, error) { } func (c *SEClient) Discover(ctx context.Context, requestedGeo string) ([]SEIPEntry, error) { - geoListInput := url.Values{ - "serial_no": {c.AssignedDeviceIDHash}, - "requested_geo": {requestedGeo}, - } - req, err := http.NewRequestWithContext( - ctx, - "POST", - c.Settings.Endpoints.Discover, - strings.NewReader(geoListInput.Encode()), - ) - if err != nil { - return nil, err - } - c.populateRequest(req) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") + c.Mux.Lock() + defer c.Mux.Unlock() - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, err - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("bad http status: %s", resp.Status) - } - - decoder := json.NewDecoder(resp.Body) var discoverRes SEDiscoverResponse - err = decoder.Decode(&discoverRes) - cleanupBody(resp.Body) - + err := c.rpcCall(ctx, c.Settings.Endpoints.Discover, StrKV{ + "serial_no": c.AssignedDeviceIDHash, + "requested_geo": requestedGeo, + }, &discoverRes) if err != nil { return nil, err } @@ -291,7 +225,57 @@ func (c *SEClient) Discover(ctx context.Context, requestedGeo string) ([]SEIPEnt return discoverRes.Data.IPs, nil } +func (c *SEClient) Login(ctx context.Context) error { + c.Mux.Lock() + defer c.Mux.Unlock() + + err := c.resetCookies() + if err != nil { + return err + } + + var loginRes SESubscriberLoginResponse + err = c.rpcCall(ctx, c.Settings.Endpoints.SubscriberLogin, StrKV{ + "login": c.SubscriberEmail, + "password": c.SubscriberPassword, + "client_type": c.Settings.ClientType, + }, &loginRes) + if err != nil { + return err + } + + if loginRes.Status.Code != SE_STATUS_OK { + return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + loginRes.Status.Code, loginRes.Status.Message) + } + return nil +} + +func (c *SEClient) DeviceGeneratePassword(ctx context.Context) error { + c.Mux.Lock() + defer c.Mux.Unlock() + + var genRes SEDeviceGeneratePasswordResponse + err := c.rpcCall(ctx, c.Settings.Endpoints.DeviceGeneratePassword, StrKV{ + "device_id": c.AssignedDeviceID, + }, &genRes) + if err != nil { + return err + } + + if genRes.Status.Code != SE_STATUS_OK { + return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", + genRes.Status.Code, genRes.Status.Message) + } + + c.DevicePassword = genRes.Data.DevicePassword + return nil +} + func (c *SEClient) GetProxyCredentials() (string, string) { + c.Mux.Lock() + defer c.Mux.Unlock() + return c.AssignedDeviceIDHash, c.DevicePassword } @@ -301,6 +285,51 @@ func (c *SEClient) populateRequest(req *http.Request) { req.Header["User-Agent"] = []string{c.Settings.UserAgent} } +func (c *SEClient) RpcCall(ctx context.Context, endpoint string, params map[string]string, res interface{}) error { + c.Mux.Lock() + defer c.Mux.Unlock() + + return c.rpcCall(ctx, endpoint, params, res) +} + +func (c *SEClient) rpcCall(ctx context.Context, endpoint string, params map[string]string, res interface{}) error { + input := make(url.Values) + for k, v := range params { + input[k] = []string{v} + } + req, err := http.NewRequestWithContext( + ctx, + "POST", + endpoint, + strings.NewReader(input.Encode()), + ) + if err != nil { + return err + } + c.populateRequest(req) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad http status: %s, headers: %#v", resp.Status, resp.Header) + } + + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(res) + cleanupBody(resp.Body) + + if err != nil { + return err + } + + return nil +} + // Does cleanup of HTTP response in order to make it reusable by keep-alive // logic of HTTP client func cleanupBody(body io.ReadCloser) { diff --git a/utils.go b/utils.go index 17b1e65..566e0a4 100644 --- a/utils.go +++ b/utils.go @@ -12,7 +12,10 @@ import ( "time" ) -const COPY_BUF = 128 * 1024 +const ( + COPY_BUF = 128 * 1024 + WALLCLOCK_PRECISION = 1 * time.Second +) func basic_auth_header(login, password string) string { return "Basic " + base64.StdEncoding.EncodeToString( @@ -143,3 +146,40 @@ func copyBody(wr io.Writer, body io.Reader) { } } } + +func AfterWallClock(d time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + deadline := time.Now().Add(d).Truncate(0) + after_ch := time.After(d) + ticker := time.NewTicker(WALLCLOCK_PRECISION) + go func() { + var t time.Time + defer ticker.Stop() + for { + select { + case t = <-after_ch: + ch <- t + return + case t = <-ticker.C: + if t.After(deadline) { + ch <- t + return + } + } + } + }() + return ch +} + +func runTicker(ctx context.Context, interval time.Duration, cb func(context.Context)) { + go func() { + for { + select { + case <-ctx.Done(): + return + case <-AfterWallClock(interval): + cb(ctx) + } + } + }() +}