diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ea0b4f..1ea90fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,13 @@ adheres to [Semantic Versioning][semver].
## [Unreleased]
-[unreleased]: https://github.com/ameshkov/udptlspipe/compare/v1.2.1...HEAD
+[unreleased]: https://github.com/ameshkov/udptlspipe/compare/v1.3.0...HEAD
+
+## [1.3.0] - 2024-02-05
+
+* Added an option to configure a probe reverse proxy URL.
+
+[1.3.0]: https://github.com/ameshkov/udptlspipe/releases/tag/v1.3.0
## [1.2.2] - 2024-02-05
diff --git a/README.md b/README.md
index b5126f5..7a8d245 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ to keep it that way.
* [How to install udptlspipe](#install)
* [How to use udptlspipe](#howtouse)
* [Custom TLS certificate](#tlscert)
+* [Probing protection](#probing)
* [Docker](#docker)
* [All command-line arguments](#allcmdarguments)
@@ -160,6 +161,23 @@ udptlspipe \
[lego]: https://go-acme.github.io/lego/usage/cli/obtain-a-certificate/
+## Probing protection
+
+By default `udptlspipe` responds with a generic `403 Forbidden` response to
+unauthorized requests. However, it allows to use a more sophisticated
+protection. If `--probe-reverseproxyurl` is specified, `udptlspipe` server will
+proxy unauthorized requests to the specified target while rewriting `Host` and
+keeping the original path. This way you can imitate a real existing website.
+
+```shell
+udptlspipe --server \
+ -l 0.0.0.0:443 \
+ -d 2.3.4.5:8123 \
+ -p SecurePassword \
+ --probe-reverseproxyurl "http://example.com"
+
+```
+
## Docker
diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go
index 70722c2..9da87d2 100644
--- a/internal/cmd/cmd.go
+++ b/internal/cmd/cmd.go
@@ -46,13 +46,14 @@ func Main() {
log.Info("Configuration:\n%s", o)
cfg := &pipe.Config{
- ListenAddr: o.ListenAddr,
- DestinationAddr: o.DestinationAddr,
- Password: o.Password,
- ServerMode: o.ServerMode,
- ProxyURL: o.ProxyURL,
- VerifyCertificate: o.VerifyCertificate,
- TLSServerName: o.TLSServerName,
+ ListenAddr: o.ListenAddr,
+ DestinationAddr: o.DestinationAddr,
+ Password: o.Password,
+ ServerMode: o.ServerMode,
+ ProxyURL: o.ProxyURL,
+ VerifyCertificate: o.VerifyCertificate,
+ TLSServerName: o.TLSServerName,
+ ProbeReverseProxyURL: o.ProbeReverseProxyURL,
}
if o.TLSCertPath != "" {
diff --git a/internal/cmd/options.go b/internal/cmd/options.go
index c371b7a..5305075 100644
--- a/internal/cmd/options.go
+++ b/internal/cmd/options.go
@@ -54,6 +54,11 @@ type Options struct {
// certificate specified by TLSCertPath.
TLSCertKey string `yaml:"tls-keyfile" long:"tls-keyfile" description:"Path to the private key for the cert specified in tls-certfile." value-name:""`
+ // ProbeReverseProxyURL is the URL that will be used by the reverse HTTP
+ // proxy to respond to unauthorized or proxy requests. If not specified,
+ // it will respond with a stub page 403 Forbidden.
+ ProbeReverseProxyURL string `yaml:"probe-reverseproxyurl" long:"probe-reverseproxyurl" description:"Unauthorized requests and probes will be proxied to the URL." value-name:""`
+
// Verbose defines whether we should write the DEBUG-level log or not.
Verbose bool `yaml:"verbose" short:"v" long:"verbose" description:"Verbose output (optional)." optional:"yes" optional-value:"true"`
}
diff --git a/internal/pipe/server.go b/internal/pipe/server.go
index 045465b..56b2be5 100644
--- a/internal/pipe/server.go
+++ b/internal/pipe/server.go
@@ -11,6 +11,7 @@ import (
"io"
"net"
"net/http"
+ "net/http/httputil"
"net/url"
"os"
"strings"
@@ -42,6 +43,9 @@ type Server struct {
dialer proxy.Dialer
serverMode bool
+ probeReverseProxyURL string
+ probeReverseProxyListen net.Listener
+
// tlsConfig to use for TLS connections. In server mode it also has the
// certificate that will be used.
tlsConfig *tls.Config
@@ -116,6 +120,11 @@ type Config struct {
// only for server mode. If not configured, the server will generate a stub
// self-signed certificate automatically.
TLSCertificate *tls.Certificate
+
+ // ProbeReverseProxyURL is the URL that will be used by the reverse HTTP
+ // proxy to respond to unauthorized or proxy requests. If not specified,
+ // it will respond with a stub page 403 Forbidden.
+ ProbeReverseProxyURL string
}
// createTLSConfig creates a TLS configuration as per the server configuration.
@@ -156,15 +165,16 @@ func createTLSConfig(config *Config) (tlsConfig *tls.Config, err error) {
// NewServer creates a new instance of a *Server.
func NewServer(config *Config) (s *Server, err error) {
s = &Server{
- listenAddr: config.ListenAddr,
- destinationAddr: config.DestinationAddr,
- password: config.Password,
- dialer: proxy.Direct,
- serverMode: config.ServerMode,
- srcConns: map[net.Conn]struct{}{},
- srcConnsMu: &sync.Mutex{},
- dstConns: map[net.Conn]struct{}{},
- dstConnsMu: &sync.Mutex{},
+ listenAddr: config.ListenAddr,
+ destinationAddr: config.DestinationAddr,
+ password: config.Password,
+ probeReverseProxyURL: config.ProbeReverseProxyURL,
+ dialer: proxy.Direct,
+ serverMode: config.ServerMode,
+ srcConns: map[net.Conn]struct{}{},
+ srcConnsMu: &sync.Mutex{},
+ dstConns: map[net.Conn]struct{}{},
+ dstConnsMu: &sync.Mutex{},
}
s.tlsConfig, err = createTLSConfig(config)
@@ -200,13 +210,13 @@ func (s *Server) Addr() (addr net.Addr) {
// Start starts the pipe, exits immediately if it failed to start
// listening. Start returns once all servers are considered up.
func (s *Server) Start() (err error) {
- log.Info("Starting the pipe %s", s)
+ log.Info("Starting the server %s", s)
s.lock.Lock()
defer s.lock.Unlock()
if s.started {
- return errors.New("pipe is already started")
+ return errors.New("Server is already started")
}
s.listen, err = s.createListener()
@@ -214,6 +224,13 @@ func (s *Server) Start() (err error) {
return fmt.Errorf("failed to start pipe: %w", err)
}
+ if s.probeReverseProxyURL != "" {
+ err = s.startProbeReverseProxy()
+ if err != nil {
+ return fmt.Errorf("failed to start probe reverse proxy: %w", err)
+ }
+ }
+
s.wg.Add(1)
go s.serve()
@@ -241,15 +258,63 @@ func (s *Server) createListener() (l net.Listener, err error) {
return l, nil
}
+// startProbeReverseProxy starts a reverse HTTP proxy that will be used for
+// answering unauthorized and probe requests. Returns the listener of that
+// proxy. Original request URI will be appended to proxyURL.
+func (s *Server) startProbeReverseProxy() (err error) {
+ proxyURL := s.probeReverseProxyURL
+
+ if _, err = url.Parse(proxyURL); err != nil {
+ return fmt.Errorf("reverse proxy URL must be a valid URL: %w", err)
+ }
+
+ targetURL, err := url.Parse(s.probeReverseProxyURL)
+ if err != nil {
+ return fmt.Errorf("reverse proxy URL must be a valid URL: %w", err)
+ }
+
+ handler := &httputil.ReverseProxy{
+ Rewrite: func(r *httputil.ProxyRequest) {
+ r.SetURL(targetURL)
+ r.Out.Host = targetURL.Host
+ },
+ }
+
+ srv := &http.Server{
+ ReadHeaderTimeout: upgradeTimeout,
+ Handler: handler,
+ }
+
+ s.probeReverseProxyListen, err = net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return fmt.Errorf("failed to start probe reverse proxy: %w", err)
+ }
+
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+
+ log.Info("Starting probe reverse proxy")
+ sErr := srv.Serve(s.probeReverseProxyListen)
+ log.Info("Probe reverse proxy has been stopped due to: %v", sErr)
+ }()
+
+ return nil
+}
+
// Shutdown stops the pipe and waits for all active connections to close.
func (s *Server) Shutdown(ctx context.Context) (err error) {
- log.Info("Stopping the pipe %s", s)
+ log.Info("Stopping the server %s", s)
s.stopServeLoop()
// Closing the udpConn thread.
log.OnCloserError(s.listen, log.DEBUG)
+ if s.probeReverseProxyListen != nil {
+ log.OnCloserError(s.probeReverseProxyListen, log.DEBUG)
+ }
+
// Closing active TCP connections.
s.closeConnections(s.srcConnsMu, s.srcConns)
@@ -323,7 +388,7 @@ func (s *Server) serve() {
func (s *Server) acceptConn() (err error) {
conn, err := s.listen.Accept()
if err != nil {
- // This type of errors should not lead to stopping the pipe.
+ // This type of errors should not lead to stopping the server.
if errors.Is(os.ErrDeadlineExceeded, err) {
return nil
}
@@ -338,13 +403,7 @@ func (s *Server) acceptConn() (err error) {
log.Debug("Accepted new connection from %s", conn.RemoteAddr())
- func() {
- s.srcConnsMu.Lock()
- defer s.srcConnsMu.Unlock()
-
- // Track the connection to allow unblocking reads on shutdown.
- s.srcConns[conn] = struct{}{}
- }()
+ s.saveSrcConn(conn)
s.wg.Add(1)
go s.serveConn(conn)
@@ -352,6 +411,15 @@ func (s *Server) acceptConn() (err error) {
return nil
}
+// saveSrcConn tracks the connection to allow unblocking reads on shutdown.
+func (s *Server) saveSrcConn(conn net.Conn) {
+ s.srcConnsMu.Lock()
+ defer s.srcConnsMu.Unlock()
+
+ // Track the connection to allow unblocking reads on shutdown.
+ s.srcConns[conn] = struct{}{}
+}
+
// closeSrcConn closes the source connection and cleans up after it.
func (s *Server) closeSrcConn(conn net.Conn) {
log.OnCloserError(conn, log.DEBUG)
@@ -362,6 +430,15 @@ func (s *Server) closeSrcConn(conn net.Conn) {
delete(s.srcConns, conn)
}
+// saveDstConn tracks the connection to allow unblocking reads on shutdown.
+func (s *Server) saveDstConn(conn net.Conn) {
+ s.dstConnsMu.Lock()
+ defer s.dstConnsMu.Unlock()
+
+ // Track the connection to allow unblocking reads on shutdown.
+ s.dstConns[conn] = struct{}{}
+}
+
// closeDstConn closes the destination connection and cleans up after it.
func (s *Server) closeDstConn(conn net.Conn) {
// No destination connection opened yet, do nothing.
@@ -425,23 +502,42 @@ func (s *Server) upgradeClientConn(conn net.Conn) (rwc io.ReadWriteCloser, err e
), nil
}
-// respondWithDummyPage writes a dummy response to the client (part of active
-// probing protection).
-func (s *Server) respondWithDummyPage(conn net.Conn, req *http.Request) {
- response := fmt.Sprintf("%s 403 Forbidden\r\n", req.Proto) +
- "Server: nginx\r\n" +
- fmt.Sprintf("Date: %s\r\n", time.Now().Format(http.TimeFormat)) +
- "Content-Type: text/html\r\n" +
- "Connection: close\r\n" +
- "\r\n" +
- "\r\n" +
- "403 Forbidden\r\n" +
- "403 Forbidden
\r\n" +
- "
nginx\r\n" +
- "\r\n" +
- "