CycleTLS icon indicating copy to clipboard operation
CycleTLS copied to clipboard

Connection Keeping Feature

Open neverusedname opened this issue 2 years ago • 17 comments
trafficstars

Hi, this lib is awesome! But there are cases where user need to keep the connection alive. Seem clcleTls does not provide such API. I'am wondering if this feature could be added. I already wrote a simple solution, perhaps I can contribute code to this lib? But I might need to talk'bout the API details with you guys, co's I didn't use some of you code, I'm not sure if this is ok :)

type FixedJa3RoutingRoundTripper struct {
	mu sync.Mutex

	userAgent     string
	tlsHelloSpec  *utls.ClientHelloSpec
	helloClientId *utls.ClientHelloID
	proxy         string // e.g. http://localhost:8888
	timeout       time.Duration

	hostPort2Delegate map[string]*singleConnTransport // www.baidu.com:443 -> RoundTripper
}

func NewFixedJa3RoutingRoundTripper(
	userAgent string,
	tlsHelloSpec *utls.ClientHelloSpec, helloClientId *utls.ClientHelloID,
	proxy string, timeout time.Duration) *FixedJa3RoutingRoundTripper {

	roundTripper := new(FixedJa3RoutingRoundTripper)

	roundTripper.userAgent = userAgent
	roundTripper.tlsHelloSpec = tlsHelloSpec
	roundTripper.helloClientId = helloClientId

	roundTripper.proxy = proxy
	roundTripper.timeout = timeout

	roundTripper.hostPort2Delegate = make(map[string]*singleConnTransport)

	return roundTripper
}

func (rt *FixedJa3RoutingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	if len(req.Header.Get("User-Agent")) == 0 {
		req.Header.Set("User-Agent", rt.userAgent)
	}

	rt.mu.Lock() // TODO: performance issue
	delegate, err := rt.routeToDelegate(req)
	rt.mu.Unlock()

	if err != nil {
		return nil, err
	}

	if delegate == nil {
		return nil, errors.New("didn't find matching delegate")
	}

	return delegate.RoundTrip(req)
}

func (rt *FixedJa3RoutingRoundTripper) routeToDelegate(req *http.Request) (http.RoundTripper, error) {
	hostAndPort := GetHostAndPort(req)
	if delegate, ok := rt.hostPort2Delegate[hostAndPort]; ok {
		return delegate, nil
	}

	builder := &transportBuilder{rt: rt, req: req}
	builder.build()

	if builder.transport != nil && builder.err == nil { // success
		rt.hostPort2Delegate[hostAndPort] = builder.transport
		return builder.transport, builder.err
	} else {
		return nil, builder.err
	}

}

type transportBuilder struct {
	rt  *FixedJa3RoutingRoundTripper
	req *http.Request

	transport *singleConnTransport
	err       error
}

func (b *transportBuilder) build() {
	if IsHttp(b.req) {
		b.buildHttpTransport()
	} else {
		b.buildHttpsTransport()
	}
}

func (b *transportBuilder) buildHttpTransport() {
	conn, err := b.establishRawTcpConnection()
	if err != nil {
		b.err = err
		return
	}

	transport := &http.Transport{
		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
			return conn, nil
		},
		MaxIdleConns: 1,
	}

	b.transport = &singleConnTransport{
		conn:         conn,
		roundTripper: transport,
	}
}

func (b *transportBuilder) buildHttpsTransport() {
	conn, err := b.establishRawTcpConnection()

	defer func() {
		if err != nil {
			b.transport = nil
			b.err = err
		}
	}()

	if err != nil {
		b.err = err
		return
	}

	tlsConn, err := b.performTlsHandshake(conn)
	if err != nil {
		return
	}

	switch tlsConn.ConnectionState().NegotiatedProtocol {
	case http2.NextProtoTLS:
		h2Transport := &http2.Transport{
			DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
				return tlsConn, nil
			},
		}

		b.transport = &singleConnTransport{
			conn:         tlsConn,
			roundTripper: h2Transport,
		}
	default:
		httpTransport := &http.Transport{
			DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
				return tlsConn, nil
			},
		}

		b.transport = &singleConnTransport{
			conn:         tlsConn,
			roundTripper: httpTransport,
		}
	}

}

func (b *transportBuilder) establishRawTcpConnection() (net.Conn, error) {
	hasProxy := len(b.rt.proxy) > 0
	if hasProxy {
		return b.connectViaProxy()
	} else { // direct connection
		return net.Dial("tcp", GetHostAndPort(b.req))
	}
}

func (b *transportBuilder) connectViaProxy() (conn net.Conn, err error) {
	defer func() {
		if err != nil && conn != nil {
			_ = conn.Close()
		}
	}()

	proxyUrl, err := url.Parse(b.rt.proxy)
	if err != nil {
		return
	}

	proxyHostAndPort := proxyUrl.Host // could be like localhost:8888 or just localhost

	proxyPort := proxyUrl.Port()
	if len(proxyPort) == 0 {
		if proxyUrl.Scheme == "https" {
			proxyPort = "443"
		} else if proxyUrl.Scheme == "http" {
			proxyPort = "80"
		} else {
			err = errors.New("only support http scheme proxies")
			return
		}
		proxyPort = proxyHostAndPort + ":" + proxyPort
	}

	d := &net.Dialer{Timeout: b.rt.timeout}
	conn, err = d.Dial("tcp", proxyHostAndPort)

	hostAndPort := GetHostAndPort(b.req)
	connectReq := (&http.Request{
		Method:     "CONNECT",
		URL:        &url.URL{Host: hostAndPort},
		Host:       hostAndPort,
		ProtoMinor: 1,
		ProtoMajor: 1,
	})

	if proxyUrl.User != nil {
		username := proxyUrl.User.Username()
		password, _ := proxyUrl.User.Password()
		connectReq.Header = make(http.Header)
		if len(username) > 0 && len(password) > 0 {
			auth := username + ":" + password
			basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
			connectReq.Header.Set("Proxy-Authorization", basicAuth)
		}
	}

	if err = connectReq.Write(conn); err != nil {
		return
	}

	resp, err := http.ReadResponse(bufio.NewReader(conn), connectReq)
	if err != nil {
		return
	}

	if resp.StatusCode != http.StatusOK {
		return nil, errors.New("error connection to remote server")
	}

	return
}

func (b *transportBuilder) performTlsHandshake(rawConn net.Conn) (tlsConn *utls.UConn, err error) {
	defer func() {
		if err != nil {
			_ = rawConn.Close()
		}
	}()

	host, _, err := net.SplitHostPort(GetHostAndPort(b.req))
	if err != nil {
		return
	}

	if b.rt.tlsHelloSpec != nil {
		tlsConn = utls.UClient(rawConn, &utls.Config{
			ServerName:         host,
			InsecureSkipVerify: true,
		}, utls.HelloCustom) // TODO: build a randomized.

		if err = tlsConn.ApplyPreset(b.rt.tlsHelloSpec); err != nil {
			return
		}
	} else if b.rt.helloClientId != nil {
		tlsConn = utls.UClient(rawConn, &utls.Config{
			ServerName:         host,
			InsecureSkipVerify: true,
		}, *b.rt.helloClientId) // TODO: build a randomized.
	}

	if err = tlsConn.Handshake(); err != nil {
		return
	}

	return
}

type singleConnTransport struct {
	conn         net.Conn
	roundTripper http.RoundTripper
}

func (scRt *singleConnTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	return scRt.roundTripper.RoundTrip(req)
}

neverusedname avatar Nov 16 '23 08:11 neverusedname

What do you mean by keeping the connection alive ? What's your use case ? WebSocket ? HTTP/2 Streams ? HTTP/1.1 Pipelining ?

RealAlphabet avatar Nov 17 '23 02:11 RealAlphabet

For normal http requests, just keep the underling tcp connection alive, so that later http requests are issued via this tcp connection. For now, cycletls just creates a new http.Client for every http request, and after that request is done, this client is discarded, and hence, the underlying connection is also closed. My use case is related to some spider issue, so I cannot tell the details, some websites would do strict ban if constantly establishing and closing tcp connections with the server.

neverusedname avatar Nov 17 '23 05:11 neverusedname

@neverusedname feel free to make a PR with this. Fasthttp works by reusing and caching existing connections so perhaps implementing something like that would be ideal. Establishing a new client connection on every request isn't ideal

Danny-Dasilva avatar Nov 17 '23 12:11 Danny-Dasilva

Thanks, I will give it a shot. I am not sure how to reuse the go's nethttp transport's tls connection reuse logic, ran n into a little concurrency issue yersterday, might need a little help if necessay :)

neverusedname avatar Nov 18 '23 02:11 neverusedname

@Danny-Dasilva Hi, I just came up with an idea :)

Currenlty cycletls logic is to customize http.Client Transport field to route a request to a proper http.Transport instance and let the routed transport do the RoundTrip job.

While http.Transport, support connection management, it uses a persistentConn object, which may have an alternate Transport field alt set if the conn is https, and uses that alt to do the RoundTrip job. But it only does this if the return conn is a tls.Connection.

So I am thinging, now that we customized dialTls, maybe we can modify the fhttp/transport.go and add a patch to set the alt field of pconn if pconn.conn is of utls.Uconn type, so that we can resuse http.Tranport connection management capability?

image image image image

neverusedname avatar Nov 20 '23 10:11 neverusedname

Will give it a shot when I am free :)

neverusedname avatar Nov 20 '23 10:11 neverusedname

sounds good, fasthttp does this in a similar manner storing outgoing requests and retrieving them if subsequent connections are made. Your approach should work

https://github.com/valyala/fasthttp/blob/8ecfc989d9c4c6c30232a6465ec17d5c6d85fc5f/client.go#L2422-L2425

https://github.com/valyala/fasthttp/blob/8ecfc989d9c4c6c30232a6465ec17d5c6d85fc5f/client.go#L2481-L2519

Danny-Dasilva avatar Nov 20 '23 13:11 Danny-Dasilva

Hi, I've almost done with implementing connection keeping feature, how can I push my code to the repo and create PR :), I modified both fhttp and cycletls project :)

neverusedname avatar Dec 01 '23 04:12 neverusedname

Hi, I've almost done with implementing connection keeping feature, how can I push my code to the repo and create PR :), I modified both fhttp and cycletls project :)

Hello, you can refer to these Github documentations.

https://docs.github.com/en/get-started/quickstart/contributing-to-projects

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request

RealAlphabet avatar Dec 01 '23 09:12 RealAlphabet

I see, thanks, I'll give it a shot :)

neverusedname avatar Dec 01 '23 11:12 neverusedname

I just found it trivial to apply changes on my forked repo, co's I been modifing code from the main branch's last commit point, anyhow, I will try.

neverusedname avatar Dec 01 '23 11:12 neverusedname

I just found it trivial to apply changes on my forked repo, co's I been modifing code from the main branch's last commit point, anyhow, I will try.

This is the way to use Github. If your branch doesn't appear in "Creating a Pull Request", try creating a new branch from your main branch. Open a terminal in your local fork repository and type the following.

git checkout -b http-keep-alive This command create a new branch called http-keep-alive based on the current branch and switch to it.

git push --set-upstream origin http-keep-alive This command pushes the current local branch to the remote 'http-keep-alive' and sets up the branch remotely if it doesn't exist.

Then go to the main repository (not the one you forked), click on "Pull Requests", then "New pull request".

RTFM

RealAlphabet avatar Dec 01 '23 17:12 RealAlphabet

No, I meant if i forked new repos, I may need to manually apply changes to the focked repo, co's I modified code directly on the cloned repo which I din't fork, that would be trivial, I may have to eyeball the changes, and copy and paste the modifed files from the original repo to my forked repo, though I did create a new branch. But your reply reminds me to set the origin to my forked repo, that would save a lot of the work, thanks :). btw, be nice with the picture, plz :)

neverusedname avatar Dec 02 '23 02:12 neverusedname

No, I meant if i forked new repos, I may need to manually apply changes to the focked repo, co's I modified code directly on the cloned repo which I din't fork, that would be trivial, I may have to eyeball the changes, and copy and paste the modifed files from the original repo to my forked repo, though I did create a new branch. But your reply reminds me to set the origin to my forked repo, that would save a lot of the work, thanks :). btw, be nice with the picture, plz :)

I wouldn't have explained if I'd wanted to be mean, I just wanted to share an amusing image. In that case you can do. StackOverflow - Cloning a repo from someone else's Github and pushing it to a repo on my Github

git init
git add .
git commit -m "your commit message"
git remote add origin "SSH or HTTPS link of your forked repo"
git push --set-upstream origin http-keep-alive

RealAlphabet avatar Dec 02 '23 12:12 RealAlphabet

@RealAlphabet Thanks, your advice for forkking repos worked! I've already forked repos and issued 2 PRS :)

neverusedname avatar Dec 04 '23 01:12 neverusedname

@Danny-Dasilva Hi, i've created a PR, and it's been like 2 weeks. Any feedbacks, do I need to modify my code to meet some PR requirements?

neverusedname avatar Dec 15 '23 01:12 neverusedname

any update on this? I needed this too

RANKTW avatar Jun 09 '24 06:06 RANKTW