From fcb8965987eac4dd7fadb2f6732b7b6894e9ee9e Mon Sep 17 00:00:00 2001 From: tobyxdd Date: Sat, 10 Jun 2023 19:08:00 -0700 Subject: [PATCH] feat: client http proxy --- app/client.example.yaml | 6 + app/cmd/client.go | 74 +++++++- app/internal/http/server.go | 295 +++++++++++++++++++++++++++++++ app/internal/http/server_test.go | 58 ++++++ app/internal/http/server_test.py | 26 +++ app/internal/http/test.crt | 23 +++ app/internal/http/test.key | 27 +++ 7 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 app/internal/http/server.go create mode 100644 app/internal/http/server_test.go create mode 100644 app/internal/http/server_test.py create mode 100644 app/internal/http/test.crt create mode 100644 app/internal/http/test.key diff --git a/app/client.example.yaml b/app/client.example.yaml index 34535be..df864af 100644 --- a/app/client.example.yaml +++ b/app/client.example.yaml @@ -27,3 +27,9 @@ socks5: # username: user # password: pass # disableUDP: true + +http: + listen: 127.0.0.1:8080 + # username: user + # password: pass + # realm: my_private_realm diff --git a/app/cmd/client.go b/app/cmd/client.go index 5dcdaea..4585593 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/viper" "go.uber.org/zap" + "github.com/apernet/hysteria/app/internal/http" "github.com/apernet/hysteria/app/internal/socks5" "github.com/apernet/hysteria/core/client" ) @@ -21,8 +22,11 @@ var clientCmd = &cobra.Command{ Run: runClient, } -var modeMap = map[string]func(*viper.Viper, client.Client) error{ +type modeFunc func(*viper.Viper, client.Client) error + +var modeMap = map[string]modeFunc{ "socks5": clientSOCKS5, + "http": clientHTTP, } func init() { @@ -48,17 +52,17 @@ func runClient(cmd *cobra.Command, args []string) { var wg sync.WaitGroup hasMode := false - for mode, f := range modeMap { + for mode, fn := range modeMap { v := viper.Sub(mode) if v != nil { hasMode = true wg.Add(1) - go func() { + go func(fn modeFunc) { defer wg.Done() - if err := f(v, c); err != nil { + if err := fn(v, c); err != nil { logger.Fatal("failed to run mode", zap.String("mode", mode), zap.Error(err)) } - }() + }(fn) } } if !hasMode { @@ -167,8 +171,8 @@ func clientSOCKS5(v *viper.Viper, c client.Client) error { var authFunc func(username, password string) bool username, password := v.GetString("username"), v.GetString("password") if username != "" && password != "" { - authFunc = func(username, password string) bool { - return username == username && password == password + authFunc = func(u, p string) bool { + return u == username && p == password } } s := socks5.Server{ @@ -181,6 +185,36 @@ func clientSOCKS5(v *viper.Viper, c client.Client) error { return s.Serve(l) } +func clientHTTP(v *viper.Viper, c client.Client) error { + listenAddr := v.GetString("listen") + if listenAddr == "" { + return configError{Field: "listen", Err: errors.New("listen address is empty")} + } + l, err := net.Listen("tcp", listenAddr) + if err != nil { + return configError{Field: "listen", Err: err} + } + var authFunc func(username, password string) bool + username, password := v.GetString("username"), v.GetString("password") + if username != "" && password != "" { + authFunc = func(u, p string) bool { + return u == username && p == password + } + } + realm := v.GetString("realm") + if realm == "" { + realm = "Hysteria" + } + h := http.Server{ + HyClient: c, + AuthFunc: authFunc, + AuthRealm: realm, + EventLogger: &httpLogger{}, + } + logger.Info("HTTP proxy server listening", zap.String("addr", listenAddr)) + return h.Serve(l) +} + func parseServerAddrString(addrStr string) (host, hostPort string) { h, _, err := net.SplitHostPort(addrStr) if err != nil { @@ -215,3 +249,29 @@ func (l *socks5Logger) UDPError(addr net.Addr, err error) { logger.Error("SOCKS5 UDP error", zap.String("addr", addr.String()), zap.Error(err)) } } + +type httpLogger struct{} + +func (l *httpLogger) ConnectRequest(addr net.Addr, reqAddr string) { + logger.Debug("HTTP CONNECT request", zap.String("addr", addr.String()), zap.String("reqAddr", reqAddr)) +} + +func (l *httpLogger) ConnectError(addr net.Addr, reqAddr string, err error) { + if err == nil { + logger.Debug("HTTP CONNECT closed", zap.String("addr", addr.String()), zap.String("reqAddr", reqAddr)) + } else { + logger.Error("HTTP CONNECT error", zap.String("addr", addr.String()), zap.String("reqAddr", reqAddr), zap.Error(err)) + } +} + +func (l *httpLogger) HTTPRequest(addr net.Addr, reqURL string) { + logger.Debug("HTTP request", zap.String("addr", addr.String()), zap.String("reqURL", reqURL)) +} + +func (l *httpLogger) HTTPError(addr net.Addr, reqURL string, err error) { + if err == nil { + logger.Debug("HTTP closed", zap.String("addr", addr.String()), zap.String("reqURL", reqURL)) + } else { + logger.Error("HTTP error", zap.String("addr", addr.String()), zap.String("reqURL", reqURL), zap.Error(err)) + } +} diff --git a/app/internal/http/server.go b/app/internal/http/server.go new file mode 100644 index 0000000..3919a69 --- /dev/null +++ b/app/internal/http/server.go @@ -0,0 +1,295 @@ +package http + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/apernet/hysteria/core/client" +) + +const ( + httpClientTimeout = 10 * time.Second +) + +// Server is an HTTP server using a Hysteria client as outbound. +type Server struct { + HyClient client.Client + AuthFunc func(username, password string) bool // nil = no authentication + AuthRealm string + EventLogger EventLogger + + httpClient *http.Client +} + +type EventLogger interface { + ConnectRequest(addr net.Addr, reqAddr string) + ConnectError(addr net.Addr, reqAddr string, err error) + HTTPRequest(addr net.Addr, reqURL string) + HTTPError(addr net.Addr, reqURL string, err error) +} + +func (s *Server) Serve(listener net.Listener) error { + for { + conn, err := listener.Accept() + if err != nil { + return err + } + go s.dispatch(conn) + } +} + +func (s *Server) dispatch(conn net.Conn) { + bufReader := bufio.NewReader(conn) + for { + req, err := http.ReadRequest(bufReader) + if err != nil { + // Connection error or invalid request + _ = conn.Close() + return + } + if s.AuthFunc != nil { + authOK := false + // Check the Proxy-Authorization header + pAuth := req.Header.Get("Proxy-Authorization") + if strings.HasPrefix(pAuth, "Basic ") { + userPass, err := base64.URLEncoding.DecodeString(pAuth[6:]) + if err == nil { + userPassParts := strings.SplitN(string(userPass), ":", 2) + if len(userPassParts) == 2 { + authOK = s.AuthFunc(userPassParts[0], userPassParts[1]) + } + } + } + if !authOK { + // Proxy authentication required + _ = sendProxyAuthRequired(conn, req, s.AuthRealm) + _ = conn.Close() + return + } + } + if req.Method == http.MethodConnect { + if bufReader.Buffered() > 0 { + // There is still data in the buffered reader. + // We need to get it out and put it into a cachedConn, + // so that handleConnect can read it. + data := make([]byte, bufReader.Buffered()) + _, err := io.ReadFull(bufReader, data) + if err != nil { + // Read from buffer failed, is this possible? + _ = conn.Close() + return + } + cachedConn := &cachedConn{ + Conn: conn, + Buffer: *bytes.NewBuffer(data), + } + s.handleConnect(cachedConn, req) + } else { + // No data in the buffered reader, we can just pass the original connection. + s.handleConnect(conn, req) + } + // handleConnect will take over the connection, + // i.e. it will not return until the connection is closed. + // When it returns, there will be no more requests from this connection, + // so we simply exit the loop. + return + } else { + // handleRequest on the other hand handles one request at a time, + // and returns when the request is done. It returns a bool indicating + // whether the connection should be kept alive, but itself never closes + // the connection. + keepAlive := s.handleRequest(conn, req) + if !keepAlive { + _ = conn.Close() + return + } + } + } +} + +// cachedConn is a net.Conn wrapper that first Read()s from a buffer, +// and then from the underlying net.Conn when the buffer is drained. +type cachedConn struct { + net.Conn + Buffer bytes.Buffer +} + +func (c *cachedConn) Read(b []byte) (int, error) { + if c.Buffer.Len() > 0 { + n, err := c.Buffer.Read(b) + if err == io.EOF { + // Buffer is drained, hide it from the caller + err = nil + } + return n, err + } + return c.Conn.Read(b) +} + +func (s *Server) handleConnect(conn net.Conn, req *http.Request) { + defer conn.Close() + + port := req.URL.Port() + if port == "" { + // HTTP defaults to port 80 + port = "80" + } + reqAddr := net.JoinHostPort(req.URL.Hostname(), port) + + // Connect request & error log + if s.EventLogger != nil { + s.EventLogger.ConnectRequest(conn.RemoteAddr(), reqAddr) + } + var closeErr error + defer func() { + if s.EventLogger != nil { + s.EventLogger.ConnectError(conn.RemoteAddr(), reqAddr, closeErr) + } + }() + + // Dial + rConn, err := s.HyClient.DialTCP(reqAddr) + if err != nil { + _ = sendSimpleResponse(conn, req, http.StatusBadGateway) + return + } + defer rConn.Close() + + // Send 200 OK response and start relaying + _ = sendSimpleResponse(conn, req, http.StatusOK) + copyErrChan := make(chan error, 2) + go func() { + _, err := io.Copy(rConn, conn) + copyErrChan <- err + }() + go func() { + _, err := io.Copy(conn, rConn) + copyErrChan <- err + }() + closeErr = <-copyErrChan +} + +func (s *Server) handleRequest(conn net.Conn, req *http.Request) bool { + // Some clients use Connection, some use Proxy-Connection + // https://www.oreilly.com/library/view/http-the-definitive/1565925092/re40.html + keepAlive := req.ProtoAtLeast(1, 1) && + (strings.ToLower(req.Header.Get("Proxy-Connection")) == "keep-alive" || + strings.ToLower(req.Header.Get("Connection")) == "keep-alive") + req.RequestURI = "" // Outgoing request should not have RequestURI + + removeHopByHopHeaders(req.Header) + removeExtraHTTPHostPort(req) + + if req.URL.Scheme == "" || req.URL.Host == "" { + _ = sendSimpleResponse(conn, req, http.StatusBadRequest) + return false + } + + // Request & error log + if s.EventLogger != nil { + s.EventLogger.HTTPRequest(conn.RemoteAddr(), req.URL.String()) + } + var closeErr error + defer func() { + if s.EventLogger != nil { + s.EventLogger.HTTPError(conn.RemoteAddr(), req.URL.String(), closeErr) + } + }() + + if s.httpClient == nil { + s.initHTTPClient() + } + + // Do the request and send the response back + resp, err := s.httpClient.Do(req) + if err != nil { + closeErr = err + _ = sendSimpleResponse(conn, req, http.StatusBadGateway) + return false + } + + removeHopByHopHeaders(resp.Header) + if keepAlive { + resp.Header.Set("Connection", "keep-alive") + resp.Header.Set("Proxy-Connection", "keep-alive") + resp.Header.Set("Keep-Alive", "timeout=60") + } + + closeErr = resp.Write(conn) + return closeErr == nil && keepAlive +} + +func (s *Server) initHTTPClient() { + s.httpClient = &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // HyClient doesn't support context for now + return s.HyClient.DialTCP(addr) + }, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: httpClientTimeout, + } +} + +func removeHopByHopHeaders(header http.Header) { + header.Del("Proxy-Connection") // Not in RFC but common + // https://www.ietf.org/rfc/rfc2616.txt + header.Del("Connection") + header.Del("Keep-Alive") + header.Del("Proxy-Authenticate") + header.Del("Proxy-Authorization") + header.Del("TE") + header.Del("Trailers") + header.Del("Transfer-Encoding") + header.Del("Upgrade") +} + +func removeExtraHTTPHostPort(req *http.Request) { + host := req.Host + if host == "" { + host = req.URL.Host + } + if pHost, port, err := net.SplitHostPort(host); err == nil && port == "80" { + host = pHost + } + req.Host = host + req.URL.Host = host +} + +// sendSimpleResponse sends a simple HTTP response with the given status code. +func sendSimpleResponse(conn net.Conn, req *http.Request, statusCode int) error { + resp := &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Proto: req.Proto, + ProtoMajor: req.ProtoMajor, + ProtoMinor: req.ProtoMinor, + Header: http.Header{}, + } + return resp.Write(conn) +} + +// sendProxyAuthRequired sends a 407 Proxy Authentication Required response. +func sendProxyAuthRequired(conn net.Conn, req *http.Request, realm string) error { + resp := &http.Response{ + StatusCode: http.StatusProxyAuthRequired, + Status: http.StatusText(http.StatusProxyAuthRequired), + Proto: req.Proto, + ProtoMajor: req.ProtoMajor, + ProtoMinor: req.ProtoMinor, + Header: http.Header{}, + } + resp.Header.Set("Proxy-Authenticate", fmt.Sprintf("Basic realm=%q", realm)) + return resp.Write(conn) +} diff --git a/app/internal/http/server_test.go b/app/internal/http/server_test.go new file mode 100644 index 0000000..3c942e8 --- /dev/null +++ b/app/internal/http/server_test.go @@ -0,0 +1,58 @@ +package http + +import ( + "errors" + "net" + "net/http" + "os/exec" + "testing" + + "github.com/apernet/hysteria/core/client" +) + +const ( + testCertFile = "test.crt" + testKeyFile = "test.key" +) + +type mockEchoHyClient struct{} + +func (c *mockEchoHyClient) DialTCP(addr string) (net.Conn, error) { + return net.Dial("tcp", addr) +} + +func (c *mockEchoHyClient) ListenUDP() (client.HyUDPConn, error) { + // Not implemented + return nil, errors.New("not implemented") +} + +func (c *mockEchoHyClient) Close() error { + return nil +} + +func TestServer(t *testing.T) { + // Start the server + s := &Server{ + HyClient: &mockEchoHyClient{}, + } + l, err := net.Listen("tcp", "127.0.0.1:18080") + if err != nil { + t.Fatal(err) + } + defer l.Close() + go s.Serve(l) + + // Start a test HTTP & HTTPS server + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("control is an illusion")) + }) + go http.ListenAndServe("127.0.0.1:18081", nil) + go http.ListenAndServeTLS("127.0.0.1:18082", testCertFile, testKeyFile, nil) + + // Run the Python test script + cmd := exec.Command("python", "server_test.py") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Failed to run test script: %v\n%s", err, out) + } +} diff --git a/app/internal/http/server_test.py b/app/internal/http/server_test.py new file mode 100644 index 0000000..ff4a18e --- /dev/null +++ b/app/internal/http/server_test.py @@ -0,0 +1,26 @@ +import requests + +proxies = { + 'http': 'http://127.0.0.1:18080', + 'https': 'http://127.0.0.1:18080', +} + + +def test_http(it): + for i in range(it): + r = requests.get('http://127.0.0.1:18081', proxies=proxies) + print(r.status_code, r.text) + assert r.status_code == 200 and r.text == 'control is an illusion' + + +def test_https(it): + for i in range(it): + r = requests.get('https://127.0.0.1:18082', + proxies=proxies, verify=False) + print(r.status_code, r.text) + assert r.status_code == 200 and r.text == 'control is an illusion' + + +if __name__ == '__main__': + test_http(10) + test_https(10) diff --git a/app/internal/http/test.crt b/app/internal/http/test.crt new file mode 100644 index 0000000..ecb00ed --- /dev/null +++ b/app/internal/http/test.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwTCCAqmgAwIBAgIUMeefneiCXWS2ovxNN+fJcdrOIfAwDQYJKoZIhvcNAQEL +BQAwcDELMAkGA1UEBhMCVFcxEzARBgNVBAgMClNvbWUtU3RhdGUxGTAXBgNVBAoM +EFJhbmRvbSBTdHVmZiBMTEMxEjAQBgNVBAMMCWxvY2FsaG9zdDEdMBsGCSqGSIb3 +DQEJARYOcG9vcGVyQHNoaXQuY2MwHhcNMjMwNDI3MDAyMDQ1WhcNMzMwNDI0MDAy +MDQ1WjBwMQswCQYDVQQGEwJUVzETMBEGA1UECAwKU29tZS1TdGF0ZTEZMBcGA1UE +CgwQUmFuZG9tIFN0dWZmIExMQzESMBAGA1UEAwwJbG9jYWxob3N0MR0wGwYJKoZI +hvcNAQkBFg5wb29wZXJAc2hpdC5jYzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAOU9/4AT/6fDKyEyZMMLFzUEVC8ZDJHoKZ+3g65ZFQLxRKqlEdhvOwq4 +ZsxYF0sceUPDAsdrT+km0l1jAvq6u82n6xQQ60HpKe6hOvDX7KS0dPcKa+nfEa0W +DKamBB+TzxB2dBfBNS1oUU74nBb7ttpJiKnOpRJ0/J+CwslvhJzq04AUXC/W1CtW +CbZBg1JjY0fCN+Oy1WjEqMtRSB6k5Ipk40a8NcsqReBOMZChR8elruZ09sIlA6tf +jICOKToDVBmkjJ8m/GnxfV8MeLoK83M2VA73njsS6q9qe9KDVgIVQmifwi6JUb7N +o0A6f2Z47AWJmvq4goHJtnQ3fyoeIsMCAwEAAaNTMFEwHQYDVR0OBBYEFPrBsm6v +M29fKA3is22tK8yHYQaDMB8GA1UdIwQYMBaAFPrBsm6vM29fKA3is22tK8yHYQaD +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJvOwj0Tf8l9AWvf +1ZLyW0K3m5oJAoUayjlLP9q7KHgJHWd4QXxg4ApUDo523m4Own3FwtN06KCMqlxc +luDJi27ghRzZ8bpB9fUujikC1rs1oWYRz/K+JSO1VItan+azm9AQRj+nNepjUiT4 +FjvRif+inC4392tcKuwrqiUFmLIggtFZdsLeKUL+hRGCRjY4BZw0d1sjjPtyVNUD +UMVO8pxlCV0NU4Nmt3vulD4YshAXM+Y8yX/vPRnaNGoRrbRgCg2VORRGaZVjQMHD +OLMvqM7pFKnVg0uiSbQ3xbQJ8WeX620zKI0So2+kZt9HoI+46gd7BdNfl7mmd6K7 +ydYKuI8= +-----END CERTIFICATE----- diff --git a/app/internal/http/test.key b/app/internal/http/test.key new file mode 100644 index 0000000..d471f50 --- /dev/null +++ b/app/internal/http/test.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA5T3/gBP/p8MrITJkwwsXNQRULxkMkegpn7eDrlkVAvFEqqUR +2G87CrhmzFgXSxx5Q8MCx2tP6SbSXWMC+rq7zafrFBDrQekp7qE68NfspLR09wpr +6d8RrRYMpqYEH5PPEHZ0F8E1LWhRTvicFvu22kmIqc6lEnT8n4LCyW+EnOrTgBRc +L9bUK1YJtkGDUmNjR8I347LVaMSoy1FIHqTkimTjRrw1yypF4E4xkKFHx6Wu5nT2 +wiUDq1+MgI4pOgNUGaSMnyb8afF9Xwx4ugrzczZUDveeOxLqr2p70oNWAhVCaJ/C +LolRvs2jQDp/ZnjsBYma+riCgcm2dDd/Kh4iwwIDAQABAoIBABjiU/vJL/U8AFCI +MdviNlCw+ZprM6wa8Xm+5/JjBR7epb+IT5mY6WXOgoon/c9PdfJfFswi3/fFGQy+ +FLK21nAKjEAPXho3fy/CHK3MIon2dMPkQ7aNWlPZkuH8H3J2DwIQeaWieW1GZ50U +64yrIjwrw0P7hHuua0W9YfuPuWt29YpW5g6ilSRE0kdTzoB6TgMzlVRj6RWbxWLX +erwYFesSpLPiQrozK2yywlQsvRV2AxTlf5woJyRTyCqcao5jNZOJJl0mqeGKNKbu +1iYGtZl9aj1XIRxUt+JB2IMKNJasygIp+GRLUDCHKh8RVFwRlVaSNcWbfLDuyNWW +T3lUEjECgYEA84mrs4TLuPfklsQM4WPBdN/2Ud1r0Zn/W8icHcVc/DCFXbcV4aPA +g4yyyyEkyTac2RSbSp+rfUk/pJcG6CVjwaiRIPehdtcLIUP34EdIrwPrPT7/uWVA +o/Hp1ANSILecknQXeE1qDlHVeGAq2k3vAQH2J0m7lMfar7QCBTMTMHcCgYEA8PkO +Uj9+/LoHod2eb4raH29wntis31X5FX/C/8HlmFmQplxfMxpRckzDYQELdHvDggNY +ZQo6pdE22MjCu2bk9AHa2ukMyieWm/mPe46Upr1YV2o5cWnfFFNa/LP2Ii/dWY5V +rFNsHFnrnwcWymX7OKo0Xb8xYnKhKZJAFwSpXxUCgYBPMjXj6wtU20g6vwZxRT9k +AnDXrmmhf7LK5jHefJAAcsbr8t3qwpWYMejypZSQ2nGnJkxZuBLMa0WHAJX+aCpI +j8iiL+USAFxeNPwmswev4lZdVF9Uqtiad9DSYUIT4aHI/nejZ4lVnscMnjlRRIa0 +jS6/F/soJtW2zZLangFfgQKBgCOSAAUwDkSsCThhiGOasXv2bT9laI9HF4+O3m/2 +ZTfJ8Mo91GesuN0Qa77D8rbtFfz5FXFEw0d6zIfPir8y/xTtuSqbQCIPGfJIMl/g +uhyq0oGE0pnlMOLFMyceQXTmb9wqYIchgVHmDBvbZgfWafEBXt1/vYB0v0ltpzw+ +menJAoGBAI0hx3+mrFgA+xJBEk4oexAlro1qbNWoR7BCmLQtd49jG3eZQu4JxWH2 +kh58AIXzLl0X9t4pfMYasYL6jBGvw+AqNdo2krpiL7MWEE8w8FP/wibzqmuloziB +T7BZuCZjpcAM0IxLmQeeUK0LF0mihcqvssxveaet46mj7QoA7bGQ +-----END RSA PRIVATE KEY-----