diff --git a/.gitignore b/.gitignore index b7d70ed..e8d6afd 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ tls/ models/**/* *.model *.vocab +*.yaml + diff --git a/README.md b/README.md index ebc576a..181b4dd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GoHPTS - HTTP proxy to SOCKS5 proxy written in Go +# GoHPTS - HTTP(S) proxy to SOCKS5 proxy (chain) written in Go [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go Reference](https://pkg.go.dev/badge/github.com/shadowy-pycoder/go-http-proxy-to-socks.svg)](https://pkg.go.dev/github.com/shadowy-pycoder/go-http-proxy-to-socks) @@ -9,8 +9,8 @@ ## Introduction -`GoHPTS` CLI tool is a bridge between HTTP clients and a SOCKS5 proxy server. It listens locally as an HTTP proxy, accepts standard HTTP -or HTTPS (via CONNECT) requests and forwards the connection through a SOCKS5 proxy. Inspired by [http-proxy-to-socks](https://github.com/oyyd/http-proxy-to-socks) +`GoHPTS` CLI tool is a bridge between HTTP clients and a SOCKS5 proxy server or multiple servers (chain). It listens locally as an HTTP proxy, accepts standard HTTP +or HTTPS (via CONNECT) requests and forwards the connection through a SOCKS5 proxy. Inspired by [http-proxy-to-socks](https://github.com/oyyd/http-proxy-to-socks) and [Proxychains](https://github.com/rofl0r/proxychains-ng) Possible use case: you need to connect to external API via Postman, but this API only available from some remote server. The following commands will help you to perform such a task: @@ -31,6 +31,9 @@ Specify http server in proxy configuration of Postman ## Features +- **Proxy Chain functionality** + Supports `strict`, `dynamic`, `random`, `round_robin` chains of SOCKS5 proxy + - **DNS Leak Protection** DNS resolution occurs on SOCKS5 server side. @@ -46,6 +49,9 @@ Specify http server in proxy configuration of Postman - **SOCKS5 Authentication Support** Supports username/password authentication for SOCKS5 proxies. +- **HTTP Authentication Support** + Supports username/password authentication for HTTP proxy server. + - **Lightweight and Fast** Designed with minimal overhead and efficient request handling. @@ -59,13 +65,13 @@ You can download the binary for your platform from [Releases](https://github.com Example: ```shell -HPTS_RELEASE=v1.4.1; wget -v https://github.com/shadowy-pycoder/go-http-proxy-to-socks/releases/download/$HPTS_RELEASE/gohpts-$HPTS_RELEASE-linux-amd64.tar.gz -O gohpts && tar xvzf gohpts && mv -f gohpts-$HPTS_RELEASE-linux-amd64 gohpts && gohpts -h +HPTS_RELEASE=v1.5.0; wget -v https://github.com/shadowy-pycoder/go-http-proxy-to-socks/releases/download/$HPTS_RELEASE/gohpts-$HPTS_RELEASE-linux-amd64.tar.gz -O gohpts && tar xvzf gohpts && mv -f gohpts-$HPTS_RELEASE-linux-amd64 gohpts && ./gohpts -h ``` Alternatively, you can install it using `go install` command (requires Go [1.24](https://go.dev/doc/install) or later): ```shell -go install -ldflags "-s -w" -trimpath github.com/shadowy-pycoder/go-http-proxy-to-socks/cmd/gohpts@latest +CGO_ENABLED=0 go install -ldflags "-s -w" -trimpath github.com/shadowy-pycoder/go-http-proxy-to-socks/cmd/gohpts@latest ``` This will install the `gohpts` binary to your `$GOPATH/bin` directory. @@ -83,7 +89,6 @@ make build ```shell gohpts -h - _____ _ _ _____ _______ _____ / ____| | | | | __ \__ __/ ____| | | __ ___ | |__| | |__) | | | | (___ @@ -91,30 +96,35 @@ gohpts -h | |__| | (_) | | | | | | | ____) | \_____|\___/|_| |_|_| |_| |_____/ -GoHPTS (HTTP Proxy to SOCKS5) by shadowy-pycoder +GoHPTS (HTTP(S) Proxy to SOCKS5 proxy) by shadowy-pycoder GitHub: https://github.com/shadowy-pycoder/go-http-proxy-to-socks Usage: gohpts [OPTIONS] Options: -h Show this help message and exit. + -U string + User for HTTP proxy (basic auth). This flag invokes prompt for password (not echoed to terminal) -c string - Path to certificate PEM encoded file - -d Show logs in DEBUG mode - -j Show logs in JSON format + Path to certificate PEM encoded file + -d Show logs in DEBUG mode + -f string + Path to server configuration file in YAML format + -j Show logs in JSON format -k string - Path to private key PEM encoded file - -l value - Address of HTTP proxy server (Default: localhost:8080) - -p Password for SOCKS5 proxy (not echoed to terminal) - -s value - Address of SOCKS5 proxy server (Default: localhost:1080) + Path to private key PEM encoded file + -l string + Address of HTTP proxy server (default "127.0.0.1:8080") + -s string + Address of SOCKS5 proxy server (default "127.0.0.1:1080") -u string - User for SOCKS5 proxy - -v print version + User for SOCKS5 proxy authentication. This flag invokes prompt for password (not echoed to terminal) + -v print version ``` ## Example +### Configuration via CLI flags + ```shell gohpts -s 1080 -l 8080 -d -j ``` @@ -127,19 +137,86 @@ Output: {"level":"debug","time":"2025-05-28T06:15:22+00:00","message":"HTTP/1.1 - CONNECT - www.google.com:443"} ``` -Specify username and password fo SOCKS5 proxy server: +Specify username and password for SOCKS5 proxy server: ```shell -gohpts -s 1080 -l 8080 -d -j -u user -p +gohpts -s 1080 -l 8080 -d -j -u user SOCKS5 Password: #you will be prompted for password input here ``` +Specify username and password for HTTP proxy server: + +```shell +gohpts -s 1080 -l 8080 -d -j -U user +HTTP Password: #you will be prompted for password input here +``` + +When both `-u` and `-U` are present, you will be prompted twice + Run http proxy over TLS connection ```shell gohpts -s 1080 -l 8080 -c "path/to/certificate" -k "path/to/private/key" ``` +### Configuration via YAML file + +Run http proxy in SOCKS5 proxy chain mode (specify server settings via YAML configuration file) + +```shell +gohpts -f "path/to/proxychain/config" -d -j +``` + +Config example: + +```yaml +# Explanations for chains taken from /etc/proxychains4.conf + +# strict - Each connection will be done via chained proxies +# all proxies chained in the order as they appear in the list +# all proxies must be online to play in chain + +# dynamic - Each connection will be done via chained proxies +# all proxies chained in the order as they appear in the list +# at least one proxy must be online to play in chain +# (dead proxies are skipped) + +# random - Each connection will be done via random proxy +# (or proxy chain, see chain_len) from the list. +# this option is good to test your IDS :) + +# round_robin - Each connection will be done via chained proxies +# of chain_len length +# all proxies chained in the order as they appear in the list +# at least one proxy must be online to play in chain +# (dead proxies are skipped). +# the start of the current proxy chain is the proxy after the last +# proxy in the previously invoked proxy chain. +# if the end of the proxy chain is reached while looking for proxies +# start at the beginning again. +# These semantics are not guaranteed in a multithreaded environment. + +chain: + type: strict # dynamic, strict, random, round_robin + length: 2 # maximum number of proxy in a chain (works only for random chain and round_robin chain) +proxy_list: + - address: 127.0.0.1:1080 + username: username # username and password are optional + password: password + - address: 127.0.0.1:1081 + - address: :1082 # empty host means localhost +server: + address: 127.0.0.1:8080 # the only required field in this section + # these are for adding basic authentication + username: username + password: password + # comment out these to use HTTP instead of HTTPS + cert_file: ~/local.crt + key_file: ~/local.key +``` + +To learn more about proxy chains visit [Proxychains Github](https://github.com/rofl0r/proxychains-ng) + ## License MIT diff --git a/cmd/gohpts/cli.go b/cmd/gohpts/cli.go index 40a99b6..a93f1bf 100644 --- a/cmd/gohpts/cli.go +++ b/cmd/gohpts/cli.go @@ -4,7 +4,6 @@ import ( "flag" "fmt" "os" - "strconv" gohpts "github.com/shadowy-pycoder/go-http-proxy-to-socks" "golang.org/x/term" @@ -12,8 +11,8 @@ import ( const ( app string = "gohpts" - addrSOCKS = ":1080" - addrHTTP = ":8080" + addrSOCKS = "127.0.0.1:1080" + addrHTTP = "127.0.0.1:8080" ) const usagePrefix string = ` _____ _ _ _____ _______ _____ @@ -23,7 +22,7 @@ const usagePrefix string = ` | |__| | (_) | | | | | | | ____) | \_____|\___/|_| |_|_| |_| |_____/ -GoHPTS (HTTP Proxy to SOCKS5) by shadowy-pycoder +GoHPTS (HTTP(S) Proxy to SOCKS5 proxy) by shadowy-pycoder GitHub: https://github.com/shadowy-pycoder/go-http-proxy-to-socks Usage: gohpts [OPTIONS] @@ -32,39 +31,15 @@ Options: ` func root(args []string) error { - conf := gohpts.Config{AddrSOCKS: addrSOCKS, AddrHTTP: addrHTTP} + conf := gohpts.Config{} flags := flag.NewFlagSet(app, flag.ExitOnError) - flags.Func("s", "Address of SOCKS5 proxy server (Default: localhost:1080)", func(flagValue string) error { - i, err := strconv.Atoi(flagValue) - if err == nil { - conf.AddrSOCKS = fmt.Sprintf(":%d", i) - } else { - conf.AddrSOCKS = flagValue - } - return nil - }) - flags.StringVar(&conf.User, "u", "", "User for SOCKS5 proxy") - flags.BoolFunc("p", "Password for SOCKS5 proxy (not echoed to terminal)", func(flagValue string) error { - fmt.Print("SOCKS5 Password: ") - bytepw, err := term.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - os.Exit(1) - } - conf.Pass = string(bytepw) - fmt.Print("\033[2K\r") - return nil - }) - flags.Func("l", "Address of HTTP proxy server (Default: localhost:8080)", func(flagValue string) error { - i, err := strconv.Atoi(flagValue) - if err == nil { - conf.AddrHTTP = fmt.Sprintf(":%d", i) - } else { - conf.AddrHTTP = flagValue - } - return nil - }) - flags.StringVar(&conf.CertFile, "c", "", "Path to certificate PEM encoded file ") - flags.StringVar(&conf.KeyFile, "k", "", "Path to private key PEM encoded file ") + flags.StringVar(&conf.AddrSOCKS, "s", addrSOCKS, "Address of SOCKS5 proxy server") + flags.StringVar(&conf.User, "u", "", "User for SOCKS5 proxy authentication. This flag invokes prompt for password (not echoed to terminal)") + flags.StringVar(&conf.AddrHTTP, "l", addrHTTP, "Address of HTTP proxy server") + flags.StringVar(&conf.ServerUser, "U", "", "User for HTTP proxy (basic auth). This flag invokes prompt for password (not echoed to terminal)") + flags.StringVar(&conf.CertFile, "c", "", "Path to certificate PEM encoded file") + flags.StringVar(&conf.KeyFile, "k", "", "Path to private key PEM encoded file") + flags.StringVar(&conf.ServerConfPath, "f", "", "Path to server configuration file in YAML format") flags.BoolFunc("d", "Show logs in DEBUG mode", func(flagValue string) error { conf.Debug = true return nil @@ -87,6 +62,33 @@ func root(args []string) error { if err := flags.Parse(args); err != nil { return err } + seen := make(map[string]bool) + flags.Visit(func(f *flag.Flag) { seen[f.Name] = true }) + if seen["f"] { + for _, da := range []string{"s", "u", "U", "c", "k", "l"} { + if seen[da] { + return fmt.Errorf("-f flag only works with -d and -j flags") + } + } + } + if seen["u"] { + fmt.Print("SOCKS5 Password: ") + bytepw, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return err + } + conf.Pass = string(bytepw) + fmt.Print("\033[2K\r") + } + if seen["U"] { + fmt.Print("HTTP Password: ") + bytepw, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return err + } + conf.ServerPass = string(bytepw) + fmt.Print("\033[2K\r") + } p := gohpts.New(&conf) p.Run() return nil diff --git a/cmd/gohpts/main.go b/cmd/gohpts/main.go index 985c677..a8d41ba 100644 --- a/cmd/gohpts/main.go +++ b/cmd/gohpts/main.go @@ -7,7 +7,7 @@ import ( func main() { if err := root(os.Args[1:]); err != nil { - fmt.Fprintf(os.Stderr, "%s: %v (type '%s help' for help)\n", app, err, app) + fmt.Fprintf(os.Stderr, "%s: %v (type '%s -h' for help)\n", app, err, app) os.Exit(2) } } diff --git a/go.mod b/go.mod index 197a9e4..9b258a9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/shadowy-pycoder/go-http-proxy-to-socks go 1.24.1 require ( + github.com/goccy/go-yaml v1.18.0 github.com/rs/zerolog v1.34.0 golang.org/x/net v0.40.0 golang.org/x/term v0.32.0 diff --git a/go.sum b/go.sum index faa9e2f..e57f174 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= diff --git a/gohpts.go b/gohpts.go index 8ae1185..7c55a3b 100644 --- a/gohpts.go +++ b/gohpts.go @@ -2,31 +2,44 @@ package gohpts import ( "context" + "crypto/sha256" + "crypto/subtle" "crypto/tls" + "encoding/base64" + "errors" "fmt" "io" "log" + "math/rand" "net" "net/http" "os" "os/signal" "slices" + "strconv" "strings" "sync" + "sync/atomic" "time" + "github.com/goccy/go-yaml" "github.com/rs/zerolog" "golang.org/x/net/proxy" ) const ( - readTimeout time.Duration = 10 * time.Second - writeTimeout time.Duration = 10 * time.Second - timeout time.Duration = 10 * time.Second - flushTimeout time.Duration = 10 * time.Millisecond - kbSize int64 = 1000 + readTimeout time.Duration = 10 * time.Second + writeTimeout time.Duration = 10 * time.Second + timeout time.Duration = 10 * time.Second + hopTimeout time.Duration = 3 * time.Second + flushTimeout time.Duration = 10 * time.Millisecond + availProxyUpdateInterval time.Duration = 30 * time.Second + kbSize int64 = 1000 + rrIndexMax uint32 = 1_000_000 ) +var supportedChainTypes = []string{"strict", "dynamic", "random", "round_robin"} + // Hop-by-hop headers // https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 var hopHeaders = []string{ @@ -88,24 +101,199 @@ func isLocalAddress(addr string) bool { } type proxyApp struct { - httpServer *http.Server - sockClient *http.Client - httpClient *http.Client - sockDialer proxy.Dialer - logger *zerolog.Logger - certFile string - keyFile string + httpServer *http.Server + sockClient *http.Client + httpClient *http.Client + sockDialer proxy.Dialer + logger *zerolog.Logger + certFile string + keyFile string + httpServerAddr string + user string + pass string + proxychain Chain + proxylist []proxyEntry + rrIndex uint32 + rrIndexReset uint32 + + mu sync.RWMutex + availProxyList []proxyEntry +} + +func (p *proxyApp) printProxyChain(pc []proxyEntry) string { + var sb strings.Builder + sb.WriteString("client -> ") + sb.WriteString(p.httpServerAddr) + sb.WriteString(" -> ") + for _, pe := range pc { + sb.WriteString(pe.String()) + sb.WriteString(" -> ") + } + sb.WriteString("target") + return sb.String() +} + +func (p *proxyApp) updateSocksList() { + p.mu.Lock() + defer p.mu.Unlock() + p.availProxyList = p.availProxyList[:0] + var base proxy.Dialer = &net.Dialer{Timeout: timeout} + var dialer proxy.Dialer + var err error + failed := 0 + chainType := p.proxychain.Type + for _, pr := range p.proxylist { + auth := proxy.Auth{ + User: pr.Username, + Password: pr.Password, + } + dialer, err = proxy.SOCKS5("tcp", pr.Address, &auth, base) + if err != nil { + p.logger.Error().Err(err).Msgf("[%s] Unable to create SOCKS5 dialer %s", chainType, pr.Address) + failed++ + continue + } + ctx, cancel := context.WithTimeout(context.Background(), hopTimeout) + defer cancel() + conn, err := dialer.(proxy.ContextDialer).DialContext(ctx, "tcp", pr.Address) + if err != nil && !errors.Is(err, io.EOF) { // check for EOF to include localhost SOCKS5 in the chain + p.logger.Error().Err(err).Msgf("[%s] Unable to connect to %s", chainType, pr.Address) + failed++ + continue + } else { + if conn != nil { + conn.Close() + } + p.availProxyList = append(p.availProxyList, proxyEntry{Address: pr.Address, Username: pr.Username, Password: pr.Password}) + break + } + } + if failed == len(p.proxylist) { + p.logger.Error().Err(err).Msgf("[%s] No SOCKS5 Proxy available", chainType) + return + } + currentDialer := dialer + for _, pr := range p.proxylist[failed+1:] { + auth := proxy.Auth{ + User: pr.Username, + Password: pr.Password, + } + dialer, err = proxy.SOCKS5("tcp", pr.Address, &auth, currentDialer) + if err != nil { + p.logger.Error().Err(err).Msgf("[%s] Unable to create SOCKS5 dialer %s", chainType, pr.Address) + continue + } + // https://github.com/golang/go/issues/37549#issuecomment-1178745487 + ctx, cancel := context.WithTimeout(context.Background(), hopTimeout) + defer cancel() + conn, err := dialer.(proxy.ContextDialer).DialContext(ctx, "tcp", pr.Address) + if err != nil { + p.logger.Error().Err(err).Msgf("[%s] Unable to connect to %s", chainType, pr.Address) + continue + } + conn.Close() + currentDialer = dialer + p.availProxyList = append(p.availProxyList, proxyEntry{Address: pr.Address, Username: pr.Username, Password: pr.Password}) + } + p.logger.Debug().Msgf("[%s] Available SOCKS5 Proxy [%d/%d]: %s", chainType, + len(p.availProxyList), len(p.proxylist), p.printProxyChain(p.availProxyList)) +} + +// https://www.calhoun.io/how-to-shuffle-arrays-and-slices-in-go/ +func shuffle(vals []proxyEntry) { + r := rand.New(rand.NewSource(time.Now().Unix())) + for len(vals) > 0 { + n := len(vals) + randIndex := r.Intn(n) + vals[n-1], vals[randIndex] = vals[randIndex], vals[n-1] + vals = vals[:n-1] + } +} + +func (p *proxyApp) getSocks() (proxy.Dialer, *http.Client, error) { + if p.proxylist == nil { + return p.sockDialer, p.sockClient, nil + } + p.mu.RLock() + defer p.mu.RUnlock() + chainType := p.proxychain.Type + var chainLength int + if p.proxychain.Length > len(p.availProxyList) || p.proxychain.Length <= 0 { + chainLength = len(p.availProxyList) + } else { + chainLength = p.proxychain.Length + } + copyProxyList := make([]proxyEntry, 0, len(p.availProxyList)) + switch chainType { + case "strict", "dynamic": + copyProxyList = p.availProxyList + case "random": + copyProxyList = append(copyProxyList, p.availProxyList...) + shuffle(copyProxyList) + copyProxyList = copyProxyList[:chainLength] + case "round_robin": + var start uint32 + for { + start = atomic.LoadUint32(&p.rrIndex) + next := start + 1 + if start >= p.rrIndexReset { + p.logger.Debug().Msg("Resetting round robin index") + next = 0 + } + if atomic.CompareAndSwapUint32(&p.rrIndex, start, next) { + break + } + } + startIdx := int(start % uint32(len(p.availProxyList))) + for i := 0; i < chainLength; i++ { + idx := (startIdx + i) % len(p.availProxyList) + copyProxyList = append(copyProxyList, p.availProxyList[idx]) + } + default: + p.logger.Fatal().Msg("Unreachable") + } + if len(copyProxyList) == 0 { + p.logger.Error().Msgf("[%s] No SOCKS5 Proxy available", chainType) + return nil, nil, fmt.Errorf("no socks5 proxy available") + } + if p.proxychain.Type == "strict" && len(copyProxyList) != len(p.proxylist) { + p.logger.Error().Msgf("[%s] Not all SOCKS5 Proxy available", chainType) + return nil, nil, fmt.Errorf("not all socks5 proxy available") + } + var dialer proxy.Dialer = &net.Dialer{Timeout: timeout} + var err error + for _, pr := range copyProxyList { + auth := proxy.Auth{ + User: pr.Username, + Password: pr.Password, + } + dialer, err = proxy.SOCKS5("tcp", pr.Address, &auth, dialer) + if err != nil { + p.logger.Error().Err(err).Msgf("[%s] Unable to create SOCKS5 dialer %s", &chainType, pr.Address) + return nil, nil, err + } + } + socks := &http.Client{ + Transport: &http.Transport{ + Dial: dialer.Dial, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + p.logger.Debug().Msgf("[%s] Request chain: %s", chainType, p.printProxyChain(copyProxyList)) + return dialer, socks, nil } -func (p *proxyApp) doReq(w http.ResponseWriter, r *http.Request, socks bool) *http.Response { +func (p *proxyApp) doReq(w http.ResponseWriter, r *http.Request, sock *http.Client) *http.Response { var ( resp *http.Response err error msg string client *http.Client ) - if socks { - client = p.sockClient + if sock != nil { + client = sock msg = "Connection to SOCKS5 server failed" } else { client = p.httpClient @@ -143,33 +331,36 @@ func (p *proxyApp) handleForward(w http.ResponseWriter, r *http.Request) { var resp *http.Response var chunked bool p.httpClient.Timeout = timeout - p.sockClient.Timeout = timeout if isLocalAddress(r.Host) { - resp = p.doReq(w, req, false) + resp = p.doReq(w, req, nil) if resp == nil { return } if slices.Contains(resp.TransferEncoding, "chunked") { chunked = true p.httpClient.Timeout = 0 - p.sockClient.Timeout = 0 resp.Body.Close() - resp = p.doReq(w, req, false) + resp = p.doReq(w, req, nil) if resp == nil { return } } } else { - resp = p.doReq(w, req, true) + _, sockClient, err := p.getSocks() + if err != nil { + p.logger.Error().Err(err).Msg("Failed getting SOCKS5 client") + w.WriteHeader(http.StatusServiceUnavailable) + return + } + resp = p.doReq(w, req, sockClient) if resp == nil { return } if slices.Contains(resp.TransferEncoding, "chunked") { chunked = true - p.httpClient.Timeout = 0 - p.sockClient.Timeout = 0 + sockClient.Timeout = 0 resp.Body.Close() - resp = p.doReq(w, req, true) + resp = p.doReq(w, req, sockClient) if resp == nil { return } @@ -255,7 +446,13 @@ func (p *proxyApp) handleTunnel(w http.ResponseWriter, r *http.Request) { return } } else { - dstConn, err = p.sockDialer.Dial("tcp", r.Host) + sockDialer, _, err := p.getSocks() + if err != nil { + p.logger.Error().Err(err).Msg("Failed getting SOCKS5 client") + w.WriteHeader(http.StatusServiceUnavailable) + return + } + dstConn, err = sockDialer.Dial("tcp", r.Host) if err != nil { p.logger.Error().Err(err).Msgf("Failed connecting to %s", r.Host) http.Error(w, err.Error(), http.StatusServiceUnavailable) @@ -307,6 +504,50 @@ func (p *proxyApp) transfer(wg *sync.WaitGroup, destination io.Writer, source io p.logger.Debug().Msgf("copied %s from %s to %s", written, srcName, destName) } +func parseProxyAuth(auth string) (username, password string, ok bool) { + if auth == "" { + return "", "", false + } + const prefix = "Basic " + if len(auth) < len(prefix) || strings.ToLower(prefix) != strings.ToLower(auth[:len(prefix)]) { + return "", "", false + } + c, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) + if err != nil { + return "", "", false + } + cs := string(c) + username, password, ok = strings.Cut(cs, ":") + if !ok { + return "", "", false + } + return username, password, true +} + +func (p *proxyApp) proxyAuth(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Proxy-Authorization") + r.Header.Del("Proxy-Authorization") + username, password, ok := parseProxyAuth(auth) + if ok { + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + expectedUsernameHash := sha256.Sum256([]byte(p.user)) + expectedPasswordHash := sha256.Sum256([]byte(p.pass)) + + usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1) + + if usernameMatch && passwordMatch { + next.ServeHTTP(w, r) + return + } + } + w.Header().Set("Proxy-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Proxy Authentication Required", http.StatusProxyAuthRequired) + }) +} + func (p *proxyApp) handler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodConnect { @@ -321,6 +562,16 @@ func (p *proxyApp) Run() { done := make(chan bool) quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) + if p.proxylist != nil { + chainType := p.proxychain.Type + go func() { + for { + p.logger.Debug().Msgf("[%s] Updating available proxy", chainType) + p.updateSocksList() + time.Sleep(availProxyUpdateInterval) + } + }() + } go func() { <-quit p.logger.Info().Msg("Server is shutting down...") @@ -333,7 +584,11 @@ func (p *proxyApp) Run() { } close(done) }() - p.httpServer.Handler = p.handler() + if p.user != "" && p.pass != "" { + p.httpServer.Handler = p.proxyAuth(p.handler()) + } else { + p.httpServer.Handler = p.handler() + } if p.certFile != "" && p.keyFile != "" { if err := p.httpServer.ListenAndServeTLS(p.certFile, p.keyFile); err != nil && err != http.ErrServerClosed { p.logger.Fatal().Err(err).Msg("Unable to start HTTPS server") @@ -348,14 +603,17 @@ func (p *proxyApp) Run() { } type Config struct { - AddrHTTP string - AddrSOCKS string - Debug bool - Json bool - User string - Pass string - CertFile string - KeyFile string + AddrHTTP string + AddrSOCKS string + Debug bool + Json bool + User string + Pass string + ServerUser string + ServerPass string + CertFile string + KeyFile string + ServerConfPath string } type logWriter struct { } @@ -372,8 +630,60 @@ func (writer jsonLogWriter) Write(bytes []byte) (int, error) { time.Now().Format(time.RFC3339), strings.TrimRight(string(bytes), "\n"))) } +type proxyEntry struct { + Address string `yaml:"address"` + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` +} + +func (pe proxyEntry) String() string { + return pe.Address +} + +type Server struct { + Address string `yaml:"address"` + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + CertFile string `yaml:"cert_file,omitempty"` + KeyFile string `yaml:"key_file,omitempty"` +} +type Chain struct { + Type string `yaml:"type"` + Length int `yaml:"length"` +} + +type serverConfig struct { + Chain Chain `yaml:"chain"` + ProxyList []proxyEntry `yaml:"proxy_list"` + Server Server `yaml:"server"` +} + +func getFullAddress(v string) string { + var addr string + i, err := strconv.Atoi(v) + if err == nil { + addr = fmt.Sprintf("127.0.0.1:%d", i) + } else if strings.HasPrefix(v, ":") { + addr = fmt.Sprintf("127.0.0.1%s", v) + } else { + addr = v + } + return addr +} + +func expandPath(p string) string { + p = os.ExpandEnv(p) + if strings.HasPrefix(p, "~") { + if home, err := os.UserHomeDir(); err == nil { + return strings.Replace(p, "~", home, 1) + } + } + return p +} + func New(conf *Config) *proxyApp { var logger zerolog.Logger + var p proxyApp if conf.Json { log.SetFlags(0) log.SetOutput(new(jsonLogWriter)) @@ -382,7 +692,7 @@ func New(conf *Config) *proxyApp { log.SetFlags(0) log.SetOutput(new(logWriter)) output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: true} - output.FormatLevel = func(i interface{}) string { + output.FormatLevel = func(i any) string { return strings.ToUpper(fmt.Sprintf("| %-6s|", i)) } logger = zerolog.New(output).With().Timestamp().Logger() @@ -392,24 +702,77 @@ func New(conf *Config) *proxyApp { if conf.Debug { zerolog.SetGlobalLevel(zerolog.DebugLevel) } - auth := proxy.Auth{ - User: conf.User, - Password: conf.Pass, - } - dialer, err := proxy.SOCKS5("tcp", conf.AddrSOCKS, &auth, &net.Dialer{Timeout: timeout}) - if err != nil { - logger.Fatal().Err(err).Msg("Unable to create SOCKS5 dialer") - } - socks := &http.Client{ - Transport: &http.Transport{ - Dial: dialer.Dial, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, + p.logger = &logger + var addrHTTP, addrSOCKS, certFile, keyFile string + if conf.ServerConfPath != "" { + var sconf serverConfig + yamlFile, err := os.ReadFile(expandPath(conf.ServerConfPath)) + if err != nil { + p.logger.Fatal().Err(err).Msg("[server config] Parsing failed") + } + err = yaml.Unmarshal(yamlFile, &sconf) + if err != nil { + p.logger.Fatal().Err(err).Msg("[server config] Parsing failed") + } + if sconf.Server.Address == "" { + p.logger.Fatal().Err(err).Msg("[server config] Server address is empty") + } + addrHTTP = getFullAddress(sconf.Server.Address) + p.httpServerAddr = addrHTTP + certFile = expandPath(sconf.Server.CertFile) + keyFile = expandPath(sconf.Server.KeyFile) + p.user = sconf.Server.Username + p.pass = sconf.Server.Password + p.proxychain = sconf.Chain + p.proxylist = sconf.ProxyList + p.availProxyList = make([]proxyEntry, 0, len(p.proxylist)) + if len(p.proxylist) == 0 { + p.logger.Fatal().Msg("[server config] Proxy list is empty") + } + seen := make(map[string]struct{}) + for idx, pr := range p.proxylist { + addr := getFullAddress(pr.Address) + if _, ok := seen[addr]; !ok { + seen[addr] = struct{}{} + p.proxylist[idx].Address = addr + } else { + p.logger.Fatal().Msgf("[server config] Duplicate entry `%s`", addr) + } + } + addrSOCKS = p.printProxyChain(p.proxylist) + chainType := p.proxychain.Type + if !slices.Contains(supportedChainTypes, chainType) { + p.logger.Fatal().Msgf("[server config] Chain type `%s` is not supported", chainType) + } + p.rrIndexReset = rrIndexMax + } else { + addrSOCKS = getFullAddress(conf.AddrSOCKS) + addrHTTP = getFullAddress(conf.AddrHTTP) + p.httpServerAddr = addrHTTP + certFile = expandPath(conf.CertFile) + keyFile = expandPath(conf.KeyFile) + p.user = conf.ServerUser + p.pass = conf.ServerPass + auth := proxy.Auth{ + User: conf.User, + Password: conf.Pass, + } + dialer, err := proxy.SOCKS5("tcp", addrSOCKS, &auth, &net.Dialer{Timeout: timeout}) + if err != nil { + p.logger.Fatal().Err(err).Msg("Unable to create SOCKS5 dialer") + } + p.sockDialer = dialer + p.sockClient = &http.Client{ + Transport: &http.Transport{ + Dial: dialer.Dial, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } } hs := &http.Server{ - Addr: conf.AddrHTTP, + Addr: addrHTTP, ReadTimeout: readTimeout, WriteTimeout: writeTimeout, MaxHeaderBytes: 1 << 20, @@ -427,7 +790,8 @@ func New(conf *Config) *proxyApp { } hs.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) hs.Protocols.SetHTTP1(true) - hc := &http.Client{ + p.httpServer = hs + p.httpClient = &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, @@ -435,19 +799,17 @@ func New(conf *Config) *proxyApp { return http.ErrUseLastResponse }, } - logger.Info().Msgf("SOCKS5 Proxy: %s", conf.AddrSOCKS) - if conf.CertFile != "" && conf.KeyFile != "" { - logger.Info().Msgf("HTTPS Proxy: %s", conf.AddrHTTP) + if conf.ServerConfPath != "" { + p.logger.Info().Msgf("SOCKS5 Proxy [%s] chain: %s", p.proxychain.Type, addrSOCKS) } else { - logger.Info().Msgf("HTTP Proxy: %s", conf.AddrHTTP) + p.logger.Info().Msgf("SOCKS5 Proxy: %s", addrSOCKS) } - return &proxyApp{ - httpServer: hs, - sockClient: socks, - httpClient: hc, - sockDialer: dialer, - logger: &logger, - certFile: conf.CertFile, - keyFile: conf.KeyFile, + if certFile != "" && keyFile != "" { + p.certFile = certFile + p.keyFile = keyFile + p.logger.Info().Msgf("HTTPS Proxy: %s", p.httpServerAddr) + } else { + p.logger.Info().Msgf("HTTP Proxy: %s", p.httpServerAddr) } + return &p } diff --git a/resources/.gitignore b/resources/.gitignore new file mode 100644 index 0000000..49cb29f --- /dev/null +++ b/resources/.gitignore @@ -0,0 +1 @@ +!example_gohpts.yaml diff --git a/resources/example_gohpts.yaml b/resources/example_gohpts.yaml new file mode 100644 index 0000000..3d08f45 --- /dev/null +++ b/resources/example_gohpts.yaml @@ -0,0 +1,43 @@ +# Explanations for chains taken from /etc/proxychains4.conf + +# strict - Each connection will be done via chained proxies +# all proxies chained in the order as they appear in the list +# all proxies must be online to play in chain + +# dynamic - Each connection will be done via chained proxies +# all proxies chained in the order as they appear in the list +# at least one proxy must be online to play in chain +# (dead proxies are skipped) + +# random - Each connection will be done via random proxy +# (or proxy chain, see chain_len) from the list. +# this option is good to test your IDS :) + +# round_robin - Each connection will be done via chained proxies +# of chain_len length +# all proxies chained in the order as they appear in the list +# at least one proxy must be online to play in chain +# (dead proxies are skipped). +# the start of the current proxy chain is the proxy after the last +# proxy in the previously invoked proxy chain. +# if the end of the proxy chain is reached while looking for proxies +# start at the beginning again. +# These semantics are not guaranteed in a multithreaded environment. + +chain: + type: strict # dynamic, strict, random, round_robin + length: 2 # maximum number of proxy in a chain (works only for random chain and round_robin chain) +proxy_list: + - address: 127.0.0.1:1080 + username: username # username and password are optional + password: password + - address: 127.0.0.1:1081 + - address: :1082 # empty host means localhost +server: + address: 127.0.0.1:8080 # the only required field in this section + # these are for adding basic authentication + username: username + password: password + # comment out these to use HTTP instead of HTTPS + cert_file: ~/local.crt + key_file: ~/local.key diff --git a/version.go b/version.go index 3f31143..d511b7d 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package gohpts -const Version string = "gohpts v1.4.1" +const Version string = "gohpts v1.5.0"