From 594fde1ff8a96d7e8a1ce0f2efac313135c62d0c Mon Sep 17 00:00:00 2001
From: Toby <tobyxdd@gmail.com>
Date: Wed, 11 Oct 2023 19:54:47 -0700
Subject: [PATCH] feat: HTTP/HTTPS proxy outbound

---
 app/cmd/server.go           |  15 +++
 app/cmd/server_test.go      |   8 ++
 app/cmd/server_test.yaml    |   5 +
 extras/outbounds/ob_http.go | 190 ++++++++++++++++++++++++++++++++++++
 4 files changed, 218 insertions(+)
 create mode 100644 extras/outbounds/ob_http.go

diff --git a/app/cmd/server.go b/app/cmd/server.go
index 6221cc6..df56403 100644
--- a/app/cmd/server.go
+++ b/app/cmd/server.go
@@ -161,11 +161,17 @@ type serverConfigOutboundSOCKS5 struct {
 	Password string `mapstructure:"password"`
 }
 
+type serverConfigOutboundHTTP struct {
+	URL      string `mapstructure:"url"`
+	Insecure bool   `mapstructure:"insecure"`
+}
+
 type serverConfigOutboundEntry struct {
 	Name   string                     `mapstructure:"name"`
 	Type   string                     `mapstructure:"type"`
 	Direct serverConfigOutboundDirect `mapstructure:"direct"`
 	SOCKS5 serverConfigOutboundSOCKS5 `mapstructure:"socks5"`
+	HTTP   serverConfigOutboundHTTP   `mapstructure:"http"`
 }
 
 type serverConfigTrafficStats struct {
@@ -395,6 +401,13 @@ func serverConfigOutboundSOCKS5ToOutbound(c serverConfigOutboundSOCKS5) (outboun
 	return outbounds.NewSOCKS5Outbound(c.Addr, c.Username, c.Password), nil
 }
 
+func serverConfigOutboundHTTPToOutbound(c serverConfigOutboundHTTP) (outbounds.PluggableOutbound, error) {
+	if c.URL == "" {
+		return nil, configError{Field: "outbounds.http.url", Err: errors.New("empty http address")}
+	}
+	return outbounds.NewHTTPOutbound(c.URL, c.Insecure)
+}
+
 func (c *serverConfig) fillOutboundConfig(hyConfig *server.Config) error {
 	// Resolver, ACL, actual outbound are all implemented through the Outbound interface.
 	// Depending on the config, we build a chain like this:
@@ -421,6 +434,8 @@ func (c *serverConfig) fillOutboundConfig(hyConfig *server.Config) error {
 				ob, err = serverConfigOutboundDirectToOutbound(entry.Direct)
 			case "socks5":
 				ob, err = serverConfigOutboundSOCKS5ToOutbound(entry.SOCKS5)
+			case "http":
+				ob, err = serverConfigOutboundHTTPToOutbound(entry.HTTP)
 			default:
 				err = configError{Field: "outbounds.type", Err: errors.New("unsupported outbound type")}
 			}
diff --git a/app/cmd/server_test.go b/app/cmd/server_test.go
index 73bce1c..09bde78 100644
--- a/app/cmd/server_test.go
+++ b/app/cmd/server_test.go
@@ -123,6 +123,14 @@ func TestServerConfig(t *testing.T) {
 					Password: "Elliot Alderson",
 				},
 			},
+			{
+				Name: "weirdstuff",
+				Type: "http",
+				HTTP: serverConfigOutboundHTTP{
+					URL:      "https://eyy.lmao:4443/goofy",
+					Insecure: true,
+				},
+			},
 		},
 		TrafficStats: serverConfigTrafficStats{
 			Listen: ":9999",
diff --git a/app/cmd/server_test.yaml b/app/cmd/server_test.yaml
index 27e09f0..03c9df2 100644
--- a/app/cmd/server_test.yaml
+++ b/app/cmd/server_test.yaml
@@ -91,6 +91,11 @@ outbounds:
       addr: shady.proxy.ru:1080
       username: hackerman
       password: Elliot Alderson
+  - name: weirdstuff
+    type: http
+    http:
+      url: https://eyy.lmao:4443/goofy
+      insecure: true
 
 trafficStats:
   listen: :9999
diff --git a/extras/outbounds/ob_http.go b/extras/outbounds/ob_http.go
new file mode 100644
index 0000000..48d5aac
--- /dev/null
+++ b/extras/outbounds/ob_http.go
@@ -0,0 +1,190 @@
+package outbounds
+
+import (
+	"bufio"
+	"bytes"
+	"crypto/tls"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+)
+
+const (
+	httpRequestTimeout = 10 * time.Second
+)
+
+var (
+	errHTTPUDPNotSupported   = errors.New("UDP not supported by HTTP proxy")
+	errHTTPUnsupportedScheme = errors.New("unsupported scheme for HTTP proxy (use http:// or https://)")
+)
+
+type errHTTPRequestFailed struct {
+	Status int
+}
+
+func (e errHTTPRequestFailed) Error() string {
+	return fmt.Sprintf("HTTP request failed: %d", e.Status)
+}
+
+// httpOutbound is a PluggableOutbound that connects to the target using
+// an HTTP/HTTPS proxy server (that supports the CONNECT method).
+// HTTP proxies don't support UDP by design, so this outbound will reject
+// any UDP request with errHTTPUDPNotSupported.
+// Since HTTP proxies support using either IP or domain name as the target
+// address, it will ignore ResolveInfo in AddrEx and always only use Host.
+type httpOutbound struct {
+	Dialer     *net.Dialer
+	Addr       string
+	HTTPS      bool
+	Insecure   bool
+	ServerName string
+	BasicAuth  string // This is after Base64 encoding
+}
+
+func NewHTTPOutbound(proxyURL string, insecure bool) (PluggableOutbound, error) {
+	u, err := url.Parse(proxyURL)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return nil, errHTTPUnsupportedScheme
+	}
+	addr := u.Host
+	if u.Port() == "" {
+		if u.Scheme == "http" {
+			addr = net.JoinHostPort(u.Host, "80")
+		} else {
+			addr = net.JoinHostPort(u.Host, "443")
+		}
+	}
+	var basicAuth string
+	if u.User != nil {
+		username := u.User.Username()
+		password, _ := u.User.Password()
+		basicAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
+	}
+	return &httpOutbound{
+		Dialer:     &net.Dialer{Timeout: defaultDialerTimeout},
+		Addr:       addr,
+		HTTPS:      u.Scheme == "https",
+		Insecure:   insecure,
+		ServerName: u.Hostname(),
+		BasicAuth:  basicAuth,
+	}, nil
+}
+
+func (o *httpOutbound) dial() (net.Conn, error) {
+	conn, err := o.Dialer.Dial("tcp", o.Addr)
+	if err != nil {
+		return nil, err
+	}
+	if o.HTTPS {
+		// Wrap the connection with TLS if the proxy is HTTPS.
+		conn = tls.Client(conn, &tls.Config{
+			InsecureSkipVerify: o.Insecure,
+			ServerName:         o.Addr,
+		})
+	}
+	return conn, nil
+}
+
+func (o *httpOutbound) addrExToRequest(reqAddr *AddrEx) (*http.Request, error) {
+	req := &http.Request{
+		Method: http.MethodConnect,
+		URL: &url.URL{
+			Host: net.JoinHostPort(reqAddr.Host, strconv.Itoa(int(reqAddr.Port))),
+		},
+		Header: http.Header{
+			"Proxy-Connection": []string{"Keep-Alive"},
+		},
+	}
+	if o.BasicAuth != "" {
+		req.Header.Add("Proxy-Authorization", o.BasicAuth)
+	}
+	return req, nil
+}
+
+func (o *httpOutbound) TCP(reqAddr *AddrEx) (net.Conn, error) {
+	req, err := o.addrExToRequest(reqAddr)
+	if err != nil {
+		return nil, err
+	}
+	conn, err := o.dial()
+	if err != nil {
+		return nil, err
+	}
+	if err := req.Write(conn); err != nil {
+		_ = conn.Close()
+		return nil, err
+	}
+	if err := conn.SetDeadline(time.Now().Add(httpRequestTimeout)); err != nil {
+		_ = conn.Close()
+		return nil, err
+	}
+	bufReader := bufio.NewReader(conn)
+	resp, err := http.ReadResponse(bufReader, req)
+	if resp != nil {
+		// Don't need response body here.
+		_ = resp.Body.Close()
+	}
+	if err != nil {
+		_ = conn.Close()
+		return nil, err
+	}
+	if resp.StatusCode != http.StatusOK {
+		_ = conn.Close()
+		return nil, errHTTPRequestFailed{resp.StatusCode}
+	}
+	if err := conn.SetDeadline(time.Time{}); err != nil {
+		_ = conn.Close()
+		return nil, err
+	}
+	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 nil, err
+		}
+		cachedConn := &cachedConn{
+			Conn:   conn,
+			Buffer: *bytes.NewBuffer(data),
+		}
+		return cachedConn, nil
+	} else {
+		return conn, nil
+	}
+}
+
+func (o *httpOutbound) UDP(reqAddr *AddrEx) (UDPConn, error) {
+	return nil, errHTTPUDPNotSupported
+}
+
+// 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)
+}