From 72ffcbae5df69714fd0a5ac8cd0495394b48946a Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:11:03 +0300 Subject: [PATCH 01/11] added basic support for transparent proxy --- cmd/gohpts/cli.go | 19 ++ go.mod | 2 +- gohpts.go | 352 +++++++++++++++++++++++++++------- resources/example_gohpts.yaml | 2 +- version.go | 2 +- 5 files changed, 300 insertions(+), 77 deletions(-) diff --git a/cmd/gohpts/cli.go b/cmd/gohpts/cli.go index a93f1bf..b9dcfd9 100644 --- a/cmd/gohpts/cli.go +++ b/cmd/gohpts/cli.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "runtime" gohpts "github.com/shadowy-pycoder/go-http-proxy-to-socks" "golang.org/x/term" @@ -40,6 +41,10 @@ func root(args []string) error { 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") + if runtime.GOOS == "linux" { + flags.StringVar(&conf.TProxy, "t", "", "Address of transparent proxy server (TPROXY) (it starts along with HTTP proxy server)") + flags.StringVar(&conf.TProxyOnly, "T", "", "Address of transparent proxy server (TPROXY) (no HTTP)") + } flags.BoolFunc("d", "Show logs in DEBUG mode", func(flagValue string) error { conf.Debug = true return nil @@ -64,9 +69,22 @@ func root(args []string) error { } seen := make(map[string]bool) flags.Visit(func(f *flag.Flag) { seen[f.Name] = true }) + if seen["t"] && seen["T"] { + return fmt.Errorf("cannot specify both -t and -T flags") + } + if seen["T"] { + for _, da := range []string{"U", "c", "k", "l"} { + if seen[da] { + return fmt.Errorf("-T flag only works with -s, -u, -f, -d and -j flags") + } + } + } if seen["f"] { for _, da := range []string{"s", "u", "U", "c", "k", "l"} { if seen[da] { + if runtime.GOOS == "linux" { + return fmt.Errorf("-f flag only works with -t, -T, -d and -j flags") + } return fmt.Errorf("-f flag only works with -d and -j flags") } } @@ -89,6 +107,7 @@ func root(args []string) error { conf.ServerPass = string(bytepw) fmt.Print("\033[2K\r") } + p := gohpts.New(&conf) p.Run() return nil diff --git a/go.mod b/go.mod index 9b258a9..65e7c13 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ 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/sys v0.33.0 golang.org/x/term v0.32.0 ) require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - golang.org/x/sys v0.33.0 // indirect ) diff --git a/gohpts.go b/gohpts.go index 28b299f..e82c7e7 100644 --- a/gohpts.go +++ b/gohpts.go @@ -13,18 +13,23 @@ import ( "math/rand" "net" "net/http" + "net/netip" "os" "os/signal" + "runtime" "slices" "strconv" "strings" "sync" "sync/atomic" + "syscall" "time" + "unsafe" "github.com/goccy/go-yaml" "github.com/rs/zerolog" "golang.org/x/net/proxy" + "golang.org/x/sys/unix" ) const ( @@ -34,6 +39,7 @@ const ( hopTimeout time.Duration = 3 * time.Second flushTimeout time.Duration = 10 * time.Millisecond availProxyUpdateInterval time.Duration = 30 * time.Second + shutdownTimeout time.Duration = 5 * time.Second kbSize int64 = 1000 rrIndexMax uint32 = 1_000_000 ) @@ -109,6 +115,7 @@ type proxyapp struct { certFile string keyFile string httpServerAddr string + tproxyAddr string user string pass string proxychain chain @@ -123,7 +130,16 @@ type proxyapp struct { func (p *proxyapp) printProxyChain(pc []proxyEntry) string { var sb strings.Builder sb.WriteString("client -> ") - sb.WriteString(p.httpServerAddr) + if p.httpServerAddr != "" { + sb.WriteString(p.httpServerAddr) + if p.tproxyAddr != "" { + sb.WriteString(" | ") + sb.WriteString(p.tproxyAddr) + sb.WriteString(" (tproxy)") + } + } else if p.tproxyAddr != "" { + sb.WriteString(p.tproxyAddr) + } sb.WriteString(" -> ") for _, pe := range pc { sb.WriteString(pe.String()) @@ -558,10 +574,151 @@ func (p *proxyapp) handler() http.HandlerFunc { } } +type tproxyServer struct { + listener net.Listener + quit chan struct{} + wg sync.WaitGroup + pa *proxyapp +} + +func newTproxyServer(pa *proxyapp) *tproxyServer { + ts := &tproxyServer{ + quit: make(chan struct{}), + pa: pa, + } + ln, err := net.Listen("tcp", ts.pa.tproxyAddr) + if err != nil { + ts.pa.logger.Fatal().Err(err).Msg("") + } + ts.listener = ln + return ts +} + +func (ts *tproxyServer) ListenAndServe() { + ts.wg.Add(1) + go ts.serve() +} + +func (ts *tproxyServer) serve() { + defer ts.wg.Done() + + for { + conn, err := ts.listener.Accept() + if err != nil { + select { + case <-ts.quit: + return + default: + ts.pa.logger.Error().Err(err).Msg("") + } + } else { + ts.wg.Add(1) + go func() { + ts.handleConnection(conn) + ts.wg.Done() + }() + } + } +} + +func getsockopt(s int, level int, optname int, optval unsafe.Pointer, optlen *uint32) (err error) { + _, _, e := unix.Syscall6(unix.SYS_GETSOCKOPT, uintptr(s), uintptr(level), uintptr(optname), uintptr(optval), uintptr(unsafe.Pointer(optlen)), 0) + if e != 0 { + return e + } + return nil +} + +func (ts *tproxyServer) getOriginalDst(rawConn syscall.RawConn) (string, error) { + var originalDst unix.RawSockaddrInet4 + err := rawConn.Control(func(fd uintptr) { + optlen := uint32(unsafe.Sizeof(originalDst)) + err := getsockopt(int(fd), unix.SOL_IP, unix.SO_ORIGINAL_DST, unsafe.Pointer(&originalDst), &optlen) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] getsockopt SO_ORIGINAL_DST failed") + } + }) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed invoking control connection") + return "", err + } + dstHost := netip.AddrFrom4(originalDst.Addr) + dstPort := uint16(originalDst.Port<<8) | originalDst.Port>>8 + ts.pa.logger.Debug().Msgf("[tproxy] getsockopt SO_ORIGINAL_DST %s:%d", dstHost, dstPort) + return fmt.Sprintf("%s:%d", dstHost, dstPort), nil +} + +func (ts *tproxyServer) handleConnection(srcConn net.Conn) { + var dstConn net.Conn + defer srcConn.Close() + + rawConn, err := srcConn.(*net.TCPConn).SyscallConn() + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get raw connection") + return + } + + dst, err := ts.getOriginalDst(rawConn) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get destination address") + return + } + if isLocalAddress(dst) { + dstConn, err = net.DialTimeout("tcp", dst, timeout) + if err != nil { + ts.pa.logger.Error().Err(err).Msgf("[tproxy] Failed connecting to %s", dst) + return + } + } else { + sockDialer, _, err := ts.pa.getSocks() + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed getting SOCKS5 client") + return + } + dstConn, err = sockDialer.Dial("tcp", dst) + if err != nil { + ts.pa.logger.Error().Err(err).Msgf("[tproxy] Failed connecting to %s", dst) + return + } + } + defer dstConn.Close() + + dstConnStr := fmt.Sprintf("%s->%s->%s", dstConn.LocalAddr().String(), dstConn.RemoteAddr().String(), dst) + srcConnStr := fmt.Sprintf("%s->%s", srcConn.LocalAddr().String(), srcConn.RemoteAddr().String()) + + ts.pa.logger.Debug().Msgf("[tproxy] src: %s - dst: %s", srcConnStr, dstConnStr) + + var wg sync.WaitGroup + wg.Add(2) + go ts.pa.transfer(&wg, dstConn, srcConn, dstConnStr, srcConnStr) + go ts.pa.transfer(&wg, srcConn, dstConn, srcConnStr, dstConnStr) + wg.Wait() +} + +func (ts *tproxyServer) Shutdown() { + close(ts.quit) + ts.listener.Close() + done := make(chan struct{}) + go func() { + ts.wg.Wait() + close(done) + }() + + select { + case <-done: + ts.pa.logger.Info().Msg("[tproxy] Server gracefully shutdown") + return + case <-time.After(shutdownTimeout): + ts.pa.logger.Error().Msg("[tproxy] Server timed out waiting for connections to finish") + return + } +} + func (p *proxyapp) Run() { done := make(chan bool) quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) + tproxyServer := newTproxyServer(p) if p.proxylist != nil { chainType := p.proxychain.Type go func() { @@ -572,34 +729,51 @@ func (p *proxyapp) Run() { } }() } - go func() { - <-quit - p.logger.Info().Msg("Server is shutting down...") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if p.httpServer != nil { + go func() { + <-quit + if p.tproxyAddr != "" { + p.logger.Info().Msg("[tproxy] Server is shutting down...") + tproxyServer.Shutdown() + } + p.logger.Info().Msg("Server is shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - p.httpServer.SetKeepAlivesEnabled(false) - if err := p.httpServer.Shutdown(ctx); err != nil { - p.logger.Fatal().Err(err).Msg("Could not gracefully shutdown the server") + defer cancel() + p.httpServer.SetKeepAlivesEnabled(false) + if err := p.httpServer.Shutdown(ctx); err != nil { + p.logger.Fatal().Err(err).Msg("Could not gracefully shutdown the server") + } + close(done) + }() + if p.tproxyAddr != "" { + go tproxyServer.ListenAndServe() } - close(done) - }() - 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") + if p.user != "" && p.pass != "" { + p.httpServer.Handler = p.proxyAuth(p.handler()) + } else { + p.httpServer.Handler = p.handler() } - } else { - if err := p.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - p.logger.Fatal().Err(err).Msg("Unable to start HTTP server") + 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") + } + } else { + if err := p.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + p.logger.Fatal().Err(err).Msg("Unable to start HTTP server") + } } + p.logger.Info().Msg("Server stopped") + } else { + go func() { + <-quit + p.logger.Info().Msg("[tproxy] server is shutting down...") + tproxyServer.Shutdown() + close(done) + }() + tproxyServer.ListenAndServe() } <-done - p.logger.Info().Msg("Server stopped") } type Config struct { @@ -614,6 +788,8 @@ type Config struct { CertFile string KeyFile string ServerConfPath string + TProxy string + TProxyOnly string } type logWriter struct { } @@ -659,6 +835,9 @@ type serverConfig struct { } func getFullAddress(v string) string { + if v == "" { + return "" + } var addr string i, err := strconv.Atoi(v) if err == nil { @@ -697,12 +876,24 @@ func New(conf *Config) *proxyapp { } logger = zerolog.New(output).With().Timestamp().Logger() } - zerolog.SetGlobalLevel(zerolog.InfoLevel) if conf.Debug { zerolog.SetGlobalLevel(zerolog.DebugLevel) } p.logger = &logger + if runtime.GOOS == "linux" && conf.TProxy != "" && conf.TProxyOnly != "" { + p.logger.Fatal().Msg("Cannot specify TPRoxy and TProxyOnly at the same time") + } else if runtime.GOOS != "linux" { + conf.TProxy = "" + conf.TProxyOnly = "" + p.logger.Warn().Msg("[tproxy] functionality only available on linux system") + } + tproxyonly := conf.TProxyOnly != "" + if tproxyonly { + p.tproxyAddr = getFullAddress(conf.TProxyOnly) + } else { + p.tproxyAddr = getFullAddress(conf.TProxy) + } var addrHTTP, addrSOCKS, certFile, keyFile string if conf.ServerConfPath != "" { var sconf serverConfig @@ -714,15 +905,17 @@ func New(conf *Config) *proxyapp { 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") + if !tproxyonly { + 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 } - 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)) @@ -746,13 +939,15 @@ func New(conf *Config) *proxyapp { } p.rrIndexReset = rrIndexMax } else { + if !tproxyonly { + addrHTTP = getFullAddress(conf.AddrHTTP) + p.httpServerAddr = addrHTTP + certFile = expandPath(conf.CertFile) + keyFile = expandPath(conf.KeyFile) + p.user = conf.ServerUser + p.pass = conf.ServerPass + } 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, @@ -762,54 +957,63 @@ func New(conf *Config) *proxyapp { p.logger.Fatal().Err(err).Msg("Unable to create SOCKS5 dialer") } p.sockDialer = dialer - p.sockClient = &http.Client{ + if !tproxyonly { + p.sockClient = &http.Client{ + Transport: &http.Transport{ + Dial: dialer.Dial, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + } + } + if !tproxyonly { + hs := &http.Server{ + Addr: addrHTTP, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + MaxHeaderBytes: 1 << 20, + Protocols: new(http.Protocols), + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + }, + }, + } + hs.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) + hs.Protocols.SetHTTP1(true) + p.httpServer = hs + p.httpClient = &http.Client{ Transport: &http.Transport{ - Dial: dialer.Dial, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } } - hs := &http.Server{ - Addr: addrHTTP, - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - MaxHeaderBytes: 1 << 20, - Protocols: new(http.Protocols), - TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, - CipherSuites: []uint16{ - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, - tls.TLS_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_RSA_WITH_AES_256_CBC_SHA, - }, - }, - } - hs.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) - hs.Protocols.SetHTTP1(true) - p.httpServer = hs - p.httpClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } if conf.ServerConfPath != "" { p.logger.Info().Msgf("SOCKS5 Proxy [%s] chain: %s", p.proxychain.Type, addrSOCKS) } else { p.logger.Info().Msgf("SOCKS5 Proxy: %s", addrSOCKS) } - 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) + if !tproxyonly { + 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) + } + } + if p.tproxyAddr != "" { + p.logger.Info().Msgf("TPROXY: %s", p.tproxyAddr) } return &p } diff --git a/resources/example_gohpts.yaml b/resources/example_gohpts.yaml index 3d08f45..d4b6c9d 100644 --- a/resources/example_gohpts.yaml +++ b/resources/example_gohpts.yaml @@ -34,7 +34,7 @@ proxy_list: - 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 + address: 127.0.0.1:8080 # the only required field in this section (ignored when -T flag specified) # these are for adding basic authentication username: username password: password diff --git a/version.go b/version.go index d511b7d..a176753 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package gohpts -const Version string = "gohpts v1.5.0" +const Version string = "gohpts v1.6.0" From 5ac8830207cdd9338ebd74b50fe764ec9bbd5f37 Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:04:06 +0300 Subject: [PATCH 02/11] added the notion of proxy mode, added support for --- cmd/gohpts/cli.go | 35 ++++++++++++++---- gohpts.go | 90 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 98 insertions(+), 27 deletions(-) diff --git a/cmd/gohpts/cli.go b/cmd/gohpts/cli.go index b9dcfd9..3a0e573 100644 --- a/cmd/gohpts/cli.go +++ b/cmd/gohpts/cli.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "runtime" + "slices" gohpts "github.com/shadowy-pycoder/go-http-proxy-to-socks" "golang.org/x/term" @@ -14,6 +15,7 @@ const ( app string = "gohpts" addrSOCKS = "127.0.0.1:1080" addrHTTP = "127.0.0.1:8080" + tproxyOS = "linux" ) const usagePrefix string = ` _____ _ _ _____ _______ _____ @@ -41,9 +43,17 @@ func root(args []string) error { 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") - if runtime.GOOS == "linux" { - flags.StringVar(&conf.TProxy, "t", "", "Address of transparent proxy server (TPROXY) (it starts along with HTTP proxy server)") - flags.StringVar(&conf.TProxyOnly, "T", "", "Address of transparent proxy server (TPROXY) (no HTTP)") + if runtime.GOOS == tproxyOS { + flags.StringVar(&conf.TProxy, "t", "", "Address of transparent proxy server (it starts along with HTTP proxy server)") + flags.StringVar(&conf.TProxyOnly, "T", "", "Address of transparent proxy server (no HTTP)") + flags.Func("M", fmt.Sprintf("Transparent proxy mode: %s", gohpts.SupportedTProxyModes), func(flagValue string) error { + if !slices.Contains(gohpts.SupportedTProxyModes, flagValue) { + fmt.Fprintf(os.Stderr, "%s: %s is not supported (type '%s -h' for help)\n", app, flagValue, app) + os.Exit(2) + } + conf.TProxyMode = flagValue + return nil + }) } flags.BoolFunc("d", "Show logs in DEBUG mode", func(flagValue string) error { conf.Debug = true @@ -72,18 +82,31 @@ func root(args []string) error { if seen["t"] && seen["T"] { return fmt.Errorf("cannot specify both -t and -T flags") } + if seen["t"] { + if !seen["M"] { + return fmt.Errorf("Transparent proxy mode is not provided: -M flag") + } + } if seen["T"] { for _, da := range []string{"U", "c", "k", "l"} { if seen[da] { - return fmt.Errorf("-T flag only works with -s, -u, -f, -d and -j flags") + return fmt.Errorf("-T flag only works with -s, -u, -f, -M, -d and -j flags") } } + if !seen["M"] { + return fmt.Errorf("Transparent proxy mode is not provided: -M flag") + } + } + if seen["M"] { + if !seen["t"] && !seen["T"] { + return fmt.Errorf("Transparent proxy mode requires -t or -T flag") + } } if seen["f"] { for _, da := range []string{"s", "u", "U", "c", "k", "l"} { if seen[da] { - if runtime.GOOS == "linux" { - return fmt.Errorf("-f flag only works with -t, -T, -d and -j flags") + if runtime.GOOS == tproxyOS { + return fmt.Errorf("-f flag only works with -t, -T, -M, -d and -j flags") } return fmt.Errorf("-f flag only works with -d and -j flags") } diff --git a/gohpts.go b/gohpts.go index e82c7e7..6997b1f 100644 --- a/gohpts.go +++ b/gohpts.go @@ -39,12 +39,14 @@ const ( hopTimeout time.Duration = 3 * time.Second flushTimeout time.Duration = 10 * time.Millisecond availProxyUpdateInterval time.Duration = 30 * time.Second - shutdownTimeout time.Duration = 5 * time.Second kbSize int64 = 1000 rrIndexMax uint32 = 1_000_000 ) -var supportedChainTypes = []string{"strict", "dynamic", "random", "round_robin"} +var ( + supportedChainTypes = []string{"strict", "dynamic", "random", "round_robin"} + SupportedTProxyModes = []string{"redirect", "tproxy"} +) // Hop-by-hop headers // https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 @@ -116,6 +118,7 @@ type proxyapp struct { keyFile string httpServerAddr string tproxyAddr string + tproxyMode string user string pass string proxychain chain @@ -135,10 +138,11 @@ func (p *proxyapp) printProxyChain(pc []proxyEntry) string { if p.tproxyAddr != "" { sb.WriteString(" | ") sb.WriteString(p.tproxyAddr) - sb.WriteString(" (tproxy)") + sb.WriteString(fmt.Sprintf(" (%s)", p.tproxyMode)) } } else if p.tproxyAddr != "" { sb.WriteString(p.tproxyAddr) + sb.WriteString(fmt.Sprintf(" (%s)", p.tproxyMode)) } sb.WriteString(" -> ") for _, pe := range pc { @@ -233,6 +237,10 @@ func (p *proxyapp) getSocks() (proxy.Dialer, *http.Client, error) { p.mu.RLock() defer p.mu.RUnlock() chainType := p.proxychain.Type + if len(p.availProxyList) == 0 { + p.logger.Error().Msgf("[%s] No SOCKS5 Proxy available", chainType) + return nil, nil, fmt.Errorf("no socks5 proxy available") + } var chainLength int if p.proxychain.Length > len(p.availProxyList) || p.proxychain.Length <= 0 { chainLength = len(p.availProxyList) @@ -586,9 +594,30 @@ func newTproxyServer(pa *proxyapp) *tproxyServer { quit: make(chan struct{}), pa: pa, } - ln, err := net.Listen("tcp", ts.pa.tproxyAddr) + // https://iximiuz.com/en/posts/go-net-http-setsockopt-example/ + lc := net.ListenConfig{ + Control: func(network, address string, conn syscall.RawConn) error { + var operr error + if err := conn.Control(func(fd uintptr) { + operr = syscall.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout*1000)) + operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) + if ts.pa.tproxyMode == "tproxy" { + operr = syscall.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1) + } + }); err != nil { + return err + } + return operr + }, + } + + ln, err := lc.Listen(context.Background(), "tcp4", ts.pa.tproxyAddr) if err != nil { - ts.pa.logger.Fatal().Err(err).Msg("") + var msg string + if errors.Is(err, unix.EPERM) { + msg = "try `sudo setcap 'cap_net_admin+ep` for the binary:" + } + ts.pa.logger.Fatal().Err(err).Msg(msg) } ts.listener = ln return ts @@ -613,6 +642,10 @@ func (ts *tproxyServer) serve() { } } else { ts.wg.Add(1) + err := conn.SetDeadline(time.Now().Add(timeout)) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("") + } go func() { ts.handleConnection(conn) ts.wg.Done() @@ -644,24 +677,34 @@ func (ts *tproxyServer) getOriginalDst(rawConn syscall.RawConn) (string, error) } dstHost := netip.AddrFrom4(originalDst.Addr) dstPort := uint16(originalDst.Port<<8) | originalDst.Port>>8 - ts.pa.logger.Debug().Msgf("[tproxy] getsockopt SO_ORIGINAL_DST %s:%d", dstHost, dstPort) return fmt.Sprintf("%s:%d", dstHost, dstPort), nil } func (ts *tproxyServer) handleConnection(srcConn net.Conn) { - var dstConn net.Conn + var ( + dstConn net.Conn + dst string + err error + ) defer srcConn.Close() - - rawConn, err := srcConn.(*net.TCPConn).SyscallConn() - if err != nil { - ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get raw connection") - return - } - - dst, err := ts.getOriginalDst(rawConn) - if err != nil { - ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get destination address") - return + switch ts.pa.tproxyMode { + case "redirect": + rawConn, err := srcConn.(*net.TCPConn).SyscallConn() + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get raw connection") + return + } + dst, err = ts.getOriginalDst(rawConn) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get destination address") + return + } + ts.pa.logger.Debug().Msgf("[tproxy] getsockopt SO_ORIGINAL_DST %s", dst) + case "tproxy": + dst = srcConn.LocalAddr().String() + ts.pa.logger.Debug().Msgf("[tproxy] IP_TRANSPARENT %s", dst) + default: + ts.pa.logger.Fatal().Msg("Unknown tproxyMode") } if isLocalAddress(dst) { dstConn, err = net.DialTimeout("tcp", dst, timeout) @@ -708,7 +751,7 @@ func (ts *tproxyServer) Shutdown() { case <-done: ts.pa.logger.Info().Msg("[tproxy] Server gracefully shutdown") return - case <-time.After(shutdownTimeout): + case <-time.After(timeout): ts.pa.logger.Error().Msg("[tproxy] Server timed out waiting for connections to finish") return } @@ -737,7 +780,7 @@ func (p *proxyapp) Run() { tproxyServer.Shutdown() } p.logger.Info().Msg("Server is shutting down...") - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() p.httpServer.SetKeepAlivesEnabled(false) @@ -767,7 +810,7 @@ func (p *proxyapp) Run() { } else { go func() { <-quit - p.logger.Info().Msg("[tproxy] server is shutting down...") + p.logger.Info().Msg("[tproxy] Server is shutting down...") tproxyServer.Shutdown() close(done) }() @@ -790,6 +833,7 @@ type Config struct { ServerConfPath string TProxy string TProxyOnly string + TProxyMode string } type logWriter struct { } @@ -883,11 +927,15 @@ func New(conf *Config) *proxyapp { p.logger = &logger if runtime.GOOS == "linux" && conf.TProxy != "" && conf.TProxyOnly != "" { p.logger.Fatal().Msg("Cannot specify TPRoxy and TProxyOnly at the same time") + } else if runtime.GOOS == "linux" && conf.TProxyMode != "" && !slices.Contains(SupportedTProxyModes, conf.TProxyMode) { + p.logger.Fatal().Msg("Incorrect TProxyMode provided") } else if runtime.GOOS != "linux" { conf.TProxy = "" conf.TProxyOnly = "" + conf.TProxyMode = "" p.logger.Warn().Msg("[tproxy] functionality only available on linux system") } + p.tproxyMode = conf.TProxyMode tproxyonly := conf.TProxyOnly != "" if tproxyonly { p.tproxyAddr = getFullAddress(conf.TProxyOnly) From 60aaaa27b633df3cc3e663432648cd49f3116e4f Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 17:19:56 +0300 Subject: [PATCH 03/11] some timeout tweaks --- gohpts.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/gohpts.go b/gohpts.go index 6997b1f..c306b1d 100644 --- a/gohpts.go +++ b/gohpts.go @@ -33,8 +33,8 @@ import ( ) const ( - readTimeout time.Duration = 10 * time.Second - writeTimeout time.Duration = 10 * time.Second + readTimeout time.Duration = 3 * time.Second + writeTimeout time.Duration = 3 * time.Second timeout time.Duration = 10 * time.Second hopTimeout time.Duration = 3 * time.Second flushTimeout time.Duration = 10 * time.Millisecond @@ -46,6 +46,7 @@ const ( var ( supportedChainTypes = []string{"strict", "dynamic", "random", "round_robin"} SupportedTProxyModes = []string{"redirect", "tproxy"} + errInvalidWrite = errors.New("invalid write result") ) // Hop-by-hop headers @@ -476,7 +477,9 @@ func (p *proxyapp) handleTunnel(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) return } - dstConn, err = sockDialer.Dial("tcp", r.Host) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + dstConn, err = sockDialer.(proxy.ContextDialer).DialContext(ctx, "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) @@ -513,9 +516,56 @@ func (p *proxyapp) handleTunnel(w http.ResponseWriter, r *http.Request) { wg.Wait() } -func (p *proxyapp) transfer(wg *sync.WaitGroup, destination io.Writer, source io.Reader, destName, srcName string) { +func (p *proxyapp) copyWithTimeout(dst net.Conn, src net.Conn) (written int64, err error) { + buf := make([]byte, 32*1024) + for { + er := src.SetReadDeadline(time.Now().Add(readTimeout)) + if er != nil { + err = er + break + } + nr, er := src.Read(buf) + if nr > 0 { + er := dst.SetWriteDeadline(time.Now().Add(writeTimeout)) + if er != nil { + err = er + break + } + nw, ew := dst.Write(buf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + if ew == nil { + ew = errInvalidWrite + } + } + written += int64(nw) + if ew != nil { + if ne, ok := ew.(net.Error); ok && ne.Timeout() { + err = ne + break + } + } + if nr != nw { + err = io.ErrShortWrite + break + } + } + if er != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + err = er + break + } + if er == io.EOF { + break + } + } + } + return written, err +} + +func (p *proxyapp) transfer(wg *sync.WaitGroup, dst net.Conn, src net.Conn, destName, srcName string) { defer wg.Done() - n, err := io.Copy(destination, source) + n, err := p.copyWithTimeout(dst, src) if err != nil { p.logger.Error().Err(err).Msgf("Error during copy from %s to %s: %v", srcName, destName, err) } @@ -718,7 +768,9 @@ func (ts *tproxyServer) handleConnection(srcConn net.Conn) { ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed getting SOCKS5 client") return } - dstConn, err = sockDialer.Dial("tcp", dst) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + dstConn, err = sockDialer.(proxy.ContextDialer).DialContext(ctx, "tcp", dst) if err != nil { ts.pa.logger.Error().Err(err).Msgf("[tproxy] Failed connecting to %s", dst) return @@ -835,6 +887,7 @@ type Config struct { TProxyOnly string TProxyMode string } + type logWriter struct { } From 26605b0b7f50754ccd77da340974a5a5a27d495d Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 19:28:55 +0300 Subject: [PATCH 04/11] updated README --- README.md | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 162 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 181b4dd..79aed9f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Specify http server in proxy configuration of Postman - **Proxy Chain functionality** Supports `strict`, `dynamic`, `random`, `round_robin` chains of SOCKS5 proxy +- **Transparent proxy** + Supports `redirect` (SO_ORIGINAL_DST) and `tproxy` (IP_TRANSPARENT) modes + - **DNS Leak Protection** DNS resolution occurs on SOCKS5 server side. @@ -65,7 +68,7 @@ You can download the binary for your platform from [Releases](https://github.com Example: ```shell -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 +HPTS_RELEASE=v1.6.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): @@ -102,23 +105,29 @@ GitHub: https://github.com/shadowy-pycoder/go-http-proxy-to-socks Usage: gohpts [OPTIONS] Options: -h Show this help message and exit. + -M value + Transparent proxy mode: [redirect tproxy] + -T string + Address of transparent proxy server (no HTTP) -U string - User for HTTP proxy (basic auth). This flag invokes prompt for password (not echoed to terminal) + 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 + 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 + Path to server configuration file in YAML format + -j Show logs in JSON format -k string - Path to private key PEM encoded file + Path to private key PEM encoded file -l string - Address of HTTP proxy server (default "127.0.0.1:8080") + Address of HTTP proxy server (default "127.0.0.1:8080") -s string - Address of SOCKS5 proxy server (default "127.0.0.1:1080") + Address of SOCKS5 proxy server (default "127.0.0.1:1080") + -t string + Address of transparent proxy server (it starts along with HTTP proxy server) -u string - User for SOCKS5 proxy authentication. This flag invokes prompt for password (not echoed to terminal) - -v print version + User for SOCKS5 proxy authentication. This flag invokes prompt for password (not echoed to terminal) + -v print version ``` ## Example @@ -217,6 +226,148 @@ server: To learn more about proxy chains visit [Proxychains Github](https://github.com/rofl0r/proxychains-ng) +## Transparent proxy + +> Also known as an `intercepting proxy`, `inline proxy`, or `forced proxy`, a transparent proxy intercepts normal application layer communication without requiring any special client configuration. Clients need not be aware of the existence of the proxy. A transparent proxy is normally located between the client and the Internet, with the proxy performing some of the functions of a gateway or router +> +> -- _From [Wiki](https://en.wikipedia.org/wiki/Proxy_server)_ + +This functionality available only on Linux systems and requires `iptables` setup + +`-T` flag specifies the address for the transparent server but `GoHPTS` will be running without HTTP server. + +`-t` flag specifies the address of transparent proxy (all other functionality stays the same). + +In other words, `-T` spins up a single server, but `-t` two servers, http and tcp. + +There are two modes `redirect` and `tproxy` that can be specified by `-M` flag + +## `redirect` (Transparent proxy via NAT) + +In this mode proxying happens with `iptables` `nat` table and `REDIRECT` target. Host of incoming packet changes to the address of running `redirect` transparent proxy, but it also contains original destination that can be retrieved with `getsockopt(SO_ORIGINAL_DST)` + +To run `GoHPTS` in this mode you use `-t` or `-T` flags with `-M redirect` + +### Example + +```shell +# run the proxy +gohpts -s 1080 -t 1090 -M redirect -d +``` + +```shell +# run socks5 server on 127.0.0.1:1080 +ssh remote -D 1080 -Nf +``` + +Setup your operating system: + +```shell +# commands below require elevated privileges (you can run it with `sudo -i`) + +#enable ip forwarding +sysctl -w net.ipv4.ip_forward=1 + +# create `GOHPTS` nat chain +iptables -t nat -N GOHPTS + +# set no redirection rules for local, http proxy, ssh and redirect procy itself +iptables -t nat -A GOHPTS -d 127.0.0.0/8 -j RETURN +iptables -t nat -A GOHPTS -p tcp --dport 8080 -j RETURN +iptables -t nat -A GOHPTS -p tcp --dport 1090 -j RETURN +iptables -t nat -A GOHPTS -p tcp --dport 22 -j RETURN + +# redirect traffic to transparent proxy +iptables -t nat -A GOHPTS -p tcp -j REDIRECT --to-ports 1090 + +# setup prerouting by adding our proxy +iptables -t nat -A PREROUTING -p tcp -j GOHPTS + +# intercept local traffic for testing +iptables -t nat -A OUTPUT -p tcp -j GOHPTS +``` + +Test connection: + +```shell +curl http://example.com #traffic should be redirected via 127.0.0.1:1090 +``` + +```shell +curl --proxy http://127.0.0.1:8080 http://example.com #traffic should be redirected via 127.0.0.1:8080 +``` + +Undo everything: + +```shell +sysctl -w net.ipv4.ip_forward=0 +iptables -t nat -D PREROUTING -p tcp -j GOHPTS +iptables -t nat -D OUTPUT -p tcp -j GOHPT +iptables -t nat -F GOHPTS +iptables -t nat -X GOHPTS +``` + +## `tproxy` (Transparent proxy with IP_TRANSPARENT socket option) + +In this mode proxying happens with `iptables` `mangle` table and `TPROXY` target. Transparent proxy sees destination address as it is, it is not being rewrited by the kernel. For this to work the proxy binds with socket option `IP_TRANSPARENT`, `iptables` intercepts traffic using TPROXY target, routing rules are used marked packets to the local proxy without changing their original destination. + +This mode requires elevated privileges to run `GoHPTS`. You can do that by running the follwing command: + +```shell +sudo setcap 'cap_net_admin+ep' ~/go/bin/gohpts +``` + +To run `GoHPTS` in this mode you use `-t` or `-T` flags with `-M tproxy` + +### Example + +```shell +# run the proxy +gohpts -s 1080 -T 0.0.0.0:1090 -M tproxy -d +``` + +```shell +# run socks5 server on 127.0.0.1:1080 +ssh remote -D 1080 -Nf +``` + +Setup your operating system: + +```shell +ip netns exec ns-client ip route add default via 10.0.0.1 +sysctl -w net.ipv4.ip_forward=1 + +iptables -t mangle -A PREROUTING -i veth1 -p tcp -j TPROXY --on-port 1090 --tproxy-mark 0x1/0x1 + +ip rule add fwmark 1 lookup 100 +ip route add local 0.0.0.0/0 dev lo table 100 +``` + +Test connection: + +```shell +ip netns exec ns-client curl http://1.1.1.1 +``` + +Undo everything: + +```shell +sysctl -w net.ipv4.ip_forward=0 +iptables -t mangle -F +ip rule del fwmark 1 lookup 100 +ip route flush table 100 +ip netns del ns-client +ip link del veth1 +``` + +Learn more about transparent proxies by visiting the following links: + +- [Transparent proxy support in Linux Kernel](https://docs.kernel.org/networking/tproxy.html) +- [Transparent proxy tutorial by Gost](https://latest.gost.run/en/tutorials/redirect/) +- [Simple tproxy example](https://github.com/FarFetchd/simple_tproxy_example) +- [Golang TProxy](https://github.com/KatelynHaworth/go-tproxy) +- [Transparent Proxy Implementation using eBPF and Go](https://medium.com/all-things-ebpf/building-a-transparent-proxy-with-ebpf-50a012237e76) + ## License MIT From 81b556bfc2e7b0c2de1630b11f65c6b63cdb676e Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 19:43:10 +0300 Subject: [PATCH 05/11] updated README 2 --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 79aed9f..ad72566 100644 --- a/README.md +++ b/README.md @@ -232,15 +232,15 @@ To learn more about proxy chains visit [Proxychains Github](https://github.com/r > > -- _From [Wiki](https://en.wikipedia.org/wiki/Proxy_server)_ -This functionality available only on Linux systems and requires `iptables` setup +This functionality available only on Linux systems and requires additional setup (`iptables`, ip route, etc) -`-T` flag specifies the address for the transparent server but `GoHPTS` will be running without HTTP server. +`-T address` flag specifies the address of transparent proxy server (`GoHPTS` will be running without HTTP server). -`-t` flag specifies the address of transparent proxy (all other functionality stays the same). +`-t address` flag specifies the address of transparent proxy server (other functionality stays the same). -In other words, `-T` spins up a single server, but `-t` two servers, http and tcp. +In other words, `-T` spins up a single server, but `-t` two servers, `http` and `tcp`. -There are two modes `redirect` and `tproxy` that can be specified by `-M` flag +There are two modes `redirect` and `tproxy` that can be specified with `-M` flag ## `redirect` (Transparent proxy via NAT) @@ -290,11 +290,13 @@ iptables -t nat -A OUTPUT -p tcp -j GOHPTS Test connection: ```shell -curl http://example.com #traffic should be redirected via 127.0.0.1:1090 +#traffic should be redirected via 127.0.0.1:1090 +curl http://example.com ``` ```shell -curl --proxy http://127.0.0.1:8080 http://example.com #traffic should be redirected via 127.0.0.1:8080 +#traffic should be redirected via 127.0.0.1:8080 +curl --proxy http://127.0.0.1:8080 http://example.com ``` Undo everything: @@ -302,14 +304,14 @@ Undo everything: ```shell sysctl -w net.ipv4.ip_forward=0 iptables -t nat -D PREROUTING -p tcp -j GOHPTS -iptables -t nat -D OUTPUT -p tcp -j GOHPT +iptables -t nat -D OUTPUT -p tcp -j GOHPTS iptables -t nat -F GOHPTS iptables -t nat -X GOHPTS ``` ## `tproxy` (Transparent proxy with IP_TRANSPARENT socket option) -In this mode proxying happens with `iptables` `mangle` table and `TPROXY` target. Transparent proxy sees destination address as it is, it is not being rewrited by the kernel. For this to work the proxy binds with socket option `IP_TRANSPARENT`, `iptables` intercepts traffic using TPROXY target, routing rules are used marked packets to the local proxy without changing their original destination. +In this mode proxying happens with `iptables` `mangle` table and `TPROXY` target. Transparent proxy sees destination address as is, it is not being rewrited by the kernel. For this to work the proxy binds with socket option `IP_TRANSPARENT`, `iptables` intercepts traffic using TPROXY target, routing rules tell marked packets to go to the local proxy without changing their original destination. This mode requires elevated privileges to run `GoHPTS`. You can do that by running the follwing command: From 7ee5db0c12cfc1b23973db6dad27eb773bd507bd Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 19:53:52 +0300 Subject: [PATCH 06/11] updated README 3 --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ad72566..228c18a 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,6 @@ Options: -v print version ``` -## Example - ### Configuration via CLI flags ```shell @@ -236,13 +234,13 @@ This functionality available only on Linux systems and requires additional setup `-T address` flag specifies the address of transparent proxy server (`GoHPTS` will be running without HTTP server). -`-t address` flag specifies the address of transparent proxy server (other functionality stays the same). +`-t address` flag specifies the address of transparent proxy server (`HTTP` proxy and other functionality stays the same). In other words, `-T` spins up a single server, but `-t` two servers, `http` and `tcp`. There are two modes `redirect` and `tproxy` that can be specified with `-M` flag -## `redirect` (Transparent proxy via NAT) +## `redirect` (via _NAT_ and _SO_ORIGINAL_DST_) In this mode proxying happens with `iptables` `nat` table and `REDIRECT` target. Host of incoming packet changes to the address of running `redirect` transparent proxy, but it also contains original destination that can be retrieved with `getsockopt(SO_ORIGINAL_DST)` @@ -309,7 +307,7 @@ iptables -t nat -F GOHPTS iptables -t nat -X GOHPTS ``` -## `tproxy` (Transparent proxy with IP_TRANSPARENT socket option) +## `tproxy` (via _MANGLE_ and _IP_TRANSPARENT_) In this mode proxying happens with `iptables` `mangle` table and `TPROXY` target. Transparent proxy sees destination address as is, it is not being rewrited by the kernel. For this to work the proxy binds with socket option `IP_TRANSPARENT`, `iptables` intercepts traffic using TPROXY target, routing rules tell marked packets to go to the local proxy without changing their original destination. @@ -333,7 +331,7 @@ gohpts -s 1080 -T 0.0.0.0:1090 -M tproxy -d ssh remote -D 1080 -Nf ``` -Setup your operating system: +**Setup your operating system:** ```shell ip netns exec ns-client ip route add default via 10.0.0.1 From db5489a2f0a45246e47bbcc711effecc64e629fd Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 19:57:13 +0300 Subject: [PATCH 07/11] updated README 4 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 228c18a..bc70ce8 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ gohpts -s 1080 -T 0.0.0.0:1090 -M tproxy -d ssh remote -D 1080 -Nf ``` -**Setup your operating system:** +Setup your operating system: ```shell ip netns exec ns-client ip route add default via 10.0.0.1 @@ -360,6 +360,8 @@ ip netns del ns-client ip link del veth1 ``` +## Links + Learn more about transparent proxies by visiting the following links: - [Transparent proxy support in Linux Kernel](https://docs.kernel.org/networking/tproxy.html) From 5197832d6cc3f4cafec192aafb9d41649a187d49 Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:05:20 +0300 Subject: [PATCH 08/11] fixed wrong type in logging message --- gohpts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gohpts.go b/gohpts.go index c306b1d..40a75fe 100644 --- a/gohpts.go +++ b/gohpts.go @@ -294,7 +294,7 @@ func (p *proxyapp) getSocks() (proxy.Dialer, *http.Client, error) { } 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) + p.logger.Error().Err(err).Msgf("[%s] Unable to create SOCKS5 dialer %s", chainType, pr.Address) return nil, nil, err } } From 9bb1ee7db97e72d33686c74b14ca4caba9ee27f4 Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Wed, 11 Jun 2025 21:07:59 +0300 Subject: [PATCH 09/11] syscall->unix --- gohpts.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gohpts.go b/gohpts.go index 40a75fe..ba848fb 100644 --- a/gohpts.go +++ b/gohpts.go @@ -649,10 +649,10 @@ func newTproxyServer(pa *proxyapp) *tproxyServer { Control: func(network, address string, conn syscall.RawConn) error { var operr error if err := conn.Control(func(fd uintptr) { - operr = syscall.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout*1000)) - operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) + operr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout*1000)) + operr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) if ts.pa.tproxyMode == "tproxy" { - operr = syscall.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1) + operr = unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1) } }); err != nil { return err From fba55babe7a4b71e878e424a6a8f383d61fec3b6 Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Thu, 12 Jun 2025 07:20:17 +0300 Subject: [PATCH 10/11] made it compilable on non linux systems --- gohpts.go | 194 ++------------------------------------------ tproxy_linux.go | 196 +++++++++++++++++++++++++++++++++++++++++++++ tproxy_nonlinux.go | 42 ++++++++++ 3 files changed, 246 insertions(+), 186 deletions(-) create mode 100644 tproxy_linux.go create mode 100644 tproxy_nonlinux.go diff --git a/gohpts.go b/gohpts.go index ba848fb..0cb856d 100644 --- a/gohpts.go +++ b/gohpts.go @@ -13,7 +13,6 @@ import ( "math/rand" "net" "net/http" - "net/netip" "os" "os/signal" "runtime" @@ -22,14 +21,11 @@ import ( "strings" "sync" "sync/atomic" - "syscall" "time" - "unsafe" "github.com/goccy/go-yaml" "github.com/rs/zerolog" "golang.org/x/net/proxy" - "golang.org/x/sys/unix" ) const ( @@ -81,7 +77,7 @@ func delHopHeaders(header http.Header) { // https://datatracker.ietf.org/doc/html/rfc7230#section-6.1 func delConnectionHeaders(h http.Header) { for _, f := range h["Connection"] { - for _, sf := range strings.Split(f, ",") { + for sf := range strings.SplitSeq(f, ",") { if sf = strings.TrimSpace(sf); sf != "" { h.Del(sf) } @@ -270,7 +266,7 @@ func (p *proxyapp) getSocks() (proxy.Dialer, *http.Client, error) { } } startIdx := int(start % uint32(len(p.availProxyList))) - for i := 0; i < chainLength; i++ { + for i := range chainLength { idx := (startIdx + i) % len(p.availProxyList) copyProxyList = append(copyProxyList, p.availProxyList[idx]) } @@ -632,188 +628,14 @@ func (p *proxyapp) handler() http.HandlerFunc { } } -type tproxyServer struct { - listener net.Listener - quit chan struct{} - wg sync.WaitGroup - pa *proxyapp -} - -func newTproxyServer(pa *proxyapp) *tproxyServer { - ts := &tproxyServer{ - quit: make(chan struct{}), - pa: pa, - } - // https://iximiuz.com/en/posts/go-net-http-setsockopt-example/ - lc := net.ListenConfig{ - Control: func(network, address string, conn syscall.RawConn) error { - var operr error - if err := conn.Control(func(fd uintptr) { - operr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout*1000)) - operr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) - if ts.pa.tproxyMode == "tproxy" { - operr = unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1) - } - }); err != nil { - return err - } - return operr - }, - } - - ln, err := lc.Listen(context.Background(), "tcp4", ts.pa.tproxyAddr) - if err != nil { - var msg string - if errors.Is(err, unix.EPERM) { - msg = "try `sudo setcap 'cap_net_admin+ep` for the binary:" - } - ts.pa.logger.Fatal().Err(err).Msg(msg) - } - ts.listener = ln - return ts -} - -func (ts *tproxyServer) ListenAndServe() { - ts.wg.Add(1) - go ts.serve() -} - -func (ts *tproxyServer) serve() { - defer ts.wg.Done() - - for { - conn, err := ts.listener.Accept() - if err != nil { - select { - case <-ts.quit: - return - default: - ts.pa.logger.Error().Err(err).Msg("") - } - } else { - ts.wg.Add(1) - err := conn.SetDeadline(time.Now().Add(timeout)) - if err != nil { - ts.pa.logger.Error().Err(err).Msg("") - } - go func() { - ts.handleConnection(conn) - ts.wg.Done() - }() - } - } -} - -func getsockopt(s int, level int, optname int, optval unsafe.Pointer, optlen *uint32) (err error) { - _, _, e := unix.Syscall6(unix.SYS_GETSOCKOPT, uintptr(s), uintptr(level), uintptr(optname), uintptr(optval), uintptr(unsafe.Pointer(optlen)), 0) - if e != 0 { - return e - } - return nil -} - -func (ts *tproxyServer) getOriginalDst(rawConn syscall.RawConn) (string, error) { - var originalDst unix.RawSockaddrInet4 - err := rawConn.Control(func(fd uintptr) { - optlen := uint32(unsafe.Sizeof(originalDst)) - err := getsockopt(int(fd), unix.SOL_IP, unix.SO_ORIGINAL_DST, unsafe.Pointer(&originalDst), &optlen) - if err != nil { - ts.pa.logger.Error().Err(err).Msg("[tproxy] getsockopt SO_ORIGINAL_DST failed") - } - }) - if err != nil { - ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed invoking control connection") - return "", err - } - dstHost := netip.AddrFrom4(originalDst.Addr) - dstPort := uint16(originalDst.Port<<8) | originalDst.Port>>8 - return fmt.Sprintf("%s:%d", dstHost, dstPort), nil -} - -func (ts *tproxyServer) handleConnection(srcConn net.Conn) { - var ( - dstConn net.Conn - dst string - err error - ) - defer srcConn.Close() - switch ts.pa.tproxyMode { - case "redirect": - rawConn, err := srcConn.(*net.TCPConn).SyscallConn() - if err != nil { - ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get raw connection") - return - } - dst, err = ts.getOriginalDst(rawConn) - if err != nil { - ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get destination address") - return - } - ts.pa.logger.Debug().Msgf("[tproxy] getsockopt SO_ORIGINAL_DST %s", dst) - case "tproxy": - dst = srcConn.LocalAddr().String() - ts.pa.logger.Debug().Msgf("[tproxy] IP_TRANSPARENT %s", dst) - default: - ts.pa.logger.Fatal().Msg("Unknown tproxyMode") - } - if isLocalAddress(dst) { - dstConn, err = net.DialTimeout("tcp", dst, timeout) - if err != nil { - ts.pa.logger.Error().Err(err).Msgf("[tproxy] Failed connecting to %s", dst) - return - } - } else { - sockDialer, _, err := ts.pa.getSocks() - if err != nil { - ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed getting SOCKS5 client") - return - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - dstConn, err = sockDialer.(proxy.ContextDialer).DialContext(ctx, "tcp", dst) - if err != nil { - ts.pa.logger.Error().Err(err).Msgf("[tproxy] Failed connecting to %s", dst) - return - } - } - defer dstConn.Close() - - dstConnStr := fmt.Sprintf("%s->%s->%s", dstConn.LocalAddr().String(), dstConn.RemoteAddr().String(), dst) - srcConnStr := fmt.Sprintf("%s->%s", srcConn.LocalAddr().String(), srcConn.RemoteAddr().String()) - - ts.pa.logger.Debug().Msgf("[tproxy] src: %s - dst: %s", srcConnStr, dstConnStr) - - var wg sync.WaitGroup - wg.Add(2) - go ts.pa.transfer(&wg, dstConn, srcConn, dstConnStr, srcConnStr) - go ts.pa.transfer(&wg, srcConn, dstConn, srcConnStr, dstConnStr) - wg.Wait() -} - -func (ts *tproxyServer) Shutdown() { - close(ts.quit) - ts.listener.Close() - done := make(chan struct{}) - go func() { - ts.wg.Wait() - close(done) - }() - - select { - case <-done: - ts.pa.logger.Info().Msg("[tproxy] Server gracefully shutdown") - return - case <-time.After(timeout): - ts.pa.logger.Error().Msg("[tproxy] Server timed out waiting for connections to finish") - return - } -} - func (p *proxyapp) Run() { done := make(chan bool) quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) - tproxyServer := newTproxyServer(p) + var tproxyServer *tproxyServer + if p.tproxyAddr != "" { + tproxyServer = newTproxyServer(p) + } if p.proxylist != nil { chainType := p.proxychain.Type go func() { @@ -827,7 +649,7 @@ func (p *proxyapp) Run() { if p.httpServer != nil { go func() { <-quit - if p.tproxyAddr != "" { + if tproxyServer != nil { p.logger.Info().Msg("[tproxy] Server is shutting down...") tproxyServer.Shutdown() } @@ -841,7 +663,7 @@ func (p *proxyapp) Run() { } close(done) }() - if p.tproxyAddr != "" { + if tproxyServer != nil { go tproxyServer.ListenAndServe() } if p.user != "" && p.pass != "" { diff --git a/tproxy_linux.go b/tproxy_linux.go new file mode 100644 index 0000000..effdec0 --- /dev/null +++ b/tproxy_linux.go @@ -0,0 +1,196 @@ +//go:build linux +// +build linux + +package gohpts + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/net/proxy" + "golang.org/x/sys/unix" +) + +type tproxyServer struct { + listener net.Listener + quit chan struct{} + wg sync.WaitGroup + pa *proxyapp +} + +func newTproxyServer(pa *proxyapp) *tproxyServer { + ts := &tproxyServer{ + quit: make(chan struct{}), + pa: pa, + } + // https://iximiuz.com/en/posts/go-net-http-setsockopt-example/ + lc := net.ListenConfig{ + Control: func(network, address string, conn syscall.RawConn) error { + var operr error + if err := conn.Control(func(fd uintptr) { + operr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout*1000)) + operr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) + if ts.pa.tproxyMode == "tproxy" { + operr = unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1) + } + }); err != nil { + return err + } + return operr + }, + } + + ln, err := lc.Listen(context.Background(), "tcp4", ts.pa.tproxyAddr) + if err != nil { + var msg string + if errors.Is(err, unix.EPERM) { + msg = "try `sudo setcap 'cap_net_admin+ep` for the binary:" + } + ts.pa.logger.Fatal().Err(err).Msg(msg) + } + ts.listener = ln + return ts +} + +func (ts *tproxyServer) ListenAndServe() { + ts.wg.Add(1) + go ts.serve() +} + +func (ts *tproxyServer) serve() { + defer ts.wg.Done() + + for { + conn, err := ts.listener.Accept() + if err != nil { + select { + case <-ts.quit: + return + default: + ts.pa.logger.Error().Err(err).Msg("") + } + } else { + ts.wg.Add(1) + err := conn.SetDeadline(time.Now().Add(timeout)) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("") + } + go func() { + ts.handleConnection(conn) + ts.wg.Done() + }() + } + } +} + +func getsockopt(s int, level int, optname int, optval unsafe.Pointer, optlen *uint32) (err error) { + _, _, e := unix.Syscall6(unix.SYS_GETSOCKOPT, uintptr(s), uintptr(level), uintptr(optname), uintptr(optval), uintptr(unsafe.Pointer(optlen)), 0) + if e != 0 { + return e + } + return nil +} + +func (ts *tproxyServer) getOriginalDst(rawConn syscall.RawConn) (string, error) { + var originalDst unix.RawSockaddrInet4 + err := rawConn.Control(func(fd uintptr) { + optlen := uint32(unsafe.Sizeof(originalDst)) + err := getsockopt(int(fd), unix.SOL_IP, unix.SO_ORIGINAL_DST, unsafe.Pointer(&originalDst), &optlen) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] getsockopt SO_ORIGINAL_DST failed") + } + }) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed invoking control connection") + return "", err + } + dstHost := netip.AddrFrom4(originalDst.Addr) + dstPort := uint16(originalDst.Port<<8) | originalDst.Port>>8 + return fmt.Sprintf("%s:%d", dstHost, dstPort), nil +} + +func (ts *tproxyServer) handleConnection(srcConn net.Conn) { + var ( + dstConn net.Conn + dst string + err error + ) + defer srcConn.Close() + switch ts.pa.tproxyMode { + case "redirect": + rawConn, err := srcConn.(*net.TCPConn).SyscallConn() + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get raw connection") + return + } + dst, err = ts.getOriginalDst(rawConn) + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed to get destination address") + return + } + ts.pa.logger.Debug().Msgf("[tproxy] getsockopt SO_ORIGINAL_DST %s", dst) + case "tproxy": + dst = srcConn.LocalAddr().String() + ts.pa.logger.Debug().Msgf("[tproxy] IP_TRANSPARENT %s", dst) + default: + ts.pa.logger.Fatal().Msg("Unknown tproxyMode") + } + if isLocalAddress(dst) { + dstConn, err = net.DialTimeout("tcp", dst, timeout) + if err != nil { + ts.pa.logger.Error().Err(err).Msgf("[tproxy] Failed connecting to %s", dst) + return + } + } else { + sockDialer, _, err := ts.pa.getSocks() + if err != nil { + ts.pa.logger.Error().Err(err).Msg("[tproxy] Failed getting SOCKS5 client") + return + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + dstConn, err = sockDialer.(proxy.ContextDialer).DialContext(ctx, "tcp", dst) + if err != nil { + ts.pa.logger.Error().Err(err).Msgf("[tproxy] Failed connecting to %s", dst) + return + } + } + defer dstConn.Close() + + dstConnStr := fmt.Sprintf("%s->%s->%s", dstConn.LocalAddr().String(), dstConn.RemoteAddr().String(), dst) + srcConnStr := fmt.Sprintf("%s->%s", srcConn.LocalAddr().String(), srcConn.RemoteAddr().String()) + + ts.pa.logger.Debug().Msgf("[tproxy] src: %s - dst: %s", srcConnStr, dstConnStr) + + var wg sync.WaitGroup + wg.Add(2) + go ts.pa.transfer(&wg, dstConn, srcConn, dstConnStr, srcConnStr) + go ts.pa.transfer(&wg, srcConn, dstConn, srcConnStr, dstConnStr) + wg.Wait() +} + +func (ts *tproxyServer) Shutdown() { + close(ts.quit) + ts.listener.Close() + done := make(chan struct{}) + go func() { + ts.wg.Wait() + close(done) + }() + + select { + case <-done: + ts.pa.logger.Info().Msg("[tproxy] Server gracefully shutdown") + return + case <-time.After(timeout): + ts.pa.logger.Error().Msg("[tproxy] Server timed out waiting for connections to finish") + return + } +} diff --git a/tproxy_nonlinux.go b/tproxy_nonlinux.go new file mode 100644 index 0000000..d789aa8 --- /dev/null +++ b/tproxy_nonlinux.go @@ -0,0 +1,42 @@ +//go:build !linux +// +build !linux + +package gohpts + +import ( + "net" + "sync" + "syscall" +) + +type tproxyServer struct { + listener net.Listener + quit chan struct{} + wg sync.WaitGroup + pa *proxyapp +} + +func newTproxyServer(pa *proxyapp) *tproxyServer { + _ = pa + return nil +} + +func (ts *tproxyServer) ListenAndServe() { + ts.serve() +} + +func (ts *tproxyServer) serve() { + ts.handleConnection(nil) +} + +func (ts *tproxyServer) getOriginalDst(rawConn syscall.RawConn) (string, error) { + _ = rawConn + return "", nil +} + +func (ts *tproxyServer) handleConnection(srcConn net.Conn) { + _ = srcConn + ts.getOriginalDst(nil) +} + +func (ts *tproxyServer) Shutdown() {} From 0bf50d15a0d569aa2d5b786d67272a7773ae602e Mon Sep 17 00:00:00 2001 From: shadowy-pycoder <35629483+shadowy-pycoder@users.noreply.github.com> Date: Thu, 12 Jun 2025 09:30:13 +0300 Subject: [PATCH 11/11] fixed tcp timeout and incorrect warning --- gohpts.go | 2 +- tproxy_linux.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gohpts.go b/gohpts.go index 0cb856d..defe8e7 100644 --- a/gohpts.go +++ b/gohpts.go @@ -804,7 +804,7 @@ func New(conf *Config) *proxyapp { p.logger.Fatal().Msg("Cannot specify TPRoxy and TProxyOnly at the same time") } else if runtime.GOOS == "linux" && conf.TProxyMode != "" && !slices.Contains(SupportedTProxyModes, conf.TProxyMode) { p.logger.Fatal().Msg("Incorrect TProxyMode provided") - } else if runtime.GOOS != "linux" { + } else if runtime.GOOS != "linux" && (conf.TProxy != "" || conf.TProxyOnly != "" || conf.TProxyMode != "") { conf.TProxy = "" conf.TProxyOnly = "" conf.TProxyMode = "" diff --git a/tproxy_linux.go b/tproxy_linux.go index effdec0..315ad2d 100644 --- a/tproxy_linux.go +++ b/tproxy_linux.go @@ -35,7 +35,7 @@ func newTproxyServer(pa *proxyapp) *tproxyServer { Control: func(network, address string, conn syscall.RawConn) error { var operr error if err := conn.Control(func(fd uintptr) { - operr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout*1000)) + operr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(timeout.Milliseconds())) operr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) if ts.pa.tproxyMode == "tproxy" { operr = unix.SetsockoptInt(int(fd), unix.SOL_IP, unix.IP_TRANSPARENT, 1)