From 42927c8bdc2ed15f28a577894ef4fabfcf3e817e Mon Sep 17 00:00:00 2001 From: 9seconds Date: Fri, 27 Feb 2026 00:53:06 +0100 Subject: [PATCH 1/5] Add v2 network package --- essentials/conns.go | 25 +++++++ go.mod | 1 + go.sum | 2 + network/v2/base_http_test.go | 49 ++++++++++++ network/v2/base_network_test.go | 83 +++++++++++++++++++++ network/v2/echo_server_test.go | 106 ++++++++++++++++++++++++++ network/v2/http.go | 14 ++++ network/v2/init.go | 45 +++++++++++ network/v2/multi_network.go | 70 ++++++++++++++++++ network/v2/network.go | 88 ++++++++++++++++++++++ network/v2/proxy_network.go | 36 +++++++++ network/v2/sockopts.go | 27 +++++++ network/v2/sockopts_unix.go | 31 ++++++++ network/v2/sockopts_windows.go | 10 +++ network/v2/socks_proxy_test.go | 127 ++++++++++++++++++++++++++++++++ 15 files changed, 714 insertions(+) create mode 100644 network/v2/base_http_test.go create mode 100644 network/v2/base_network_test.go create mode 100644 network/v2/echo_server_test.go create mode 100644 network/v2/http.go create mode 100644 network/v2/init.go create mode 100644 network/v2/multi_network.go create mode 100644 network/v2/network.go create mode 100644 network/v2/proxy_network.go create mode 100644 network/v2/sockopts.go create mode 100644 network/v2/sockopts_unix.go create mode 100644 network/v2/sockopts_windows.go create mode 100644 network/v2/socks_proxy_test.go diff --git a/essentials/conns.go b/essentials/conns.go index 5b42efe..1917b6b 100644 --- a/essentials/conns.go +++ b/essentials/conns.go @@ -24,3 +24,28 @@ type Conn interface { CloseableReader CloseableWriter } + +type netConnWrapper struct { + net.Conn +} + +func (n netConnWrapper) CloseRead() error { + if conn, ok := n.Conn.(CloseableReader); ok { + return conn.CloseRead() + } + + return n.Close() +} + +func (n netConnWrapper) CloseWrite() error { + if conn, ok := n.Conn.(CloseableWriter); ok { + return conn.CloseWrite() + } + + return n.Close() +} + +// WrapConn wraps a generic [net.Conn] into Conn. +func WrapNetConn(conn net.Conn) Conn { + return netConnWrapper{conn} +} diff --git a/go.mod b/go.mod index b78ee66..96d1216 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/things-go/go-socks5 v0.1.0 // indirect github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 6c41391..a9cdd4a 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/things-go/go-socks5 v0.1.0 h1:4f5dz0iMQ6cA4wseFmyLmCHmg3SWJTW92ndrKS6oERg= +github.com/things-go/go-socks5 v0.1.0/go.mod h1:Riabiyu52kLsla0YmJqunt1c1JEl6iXSr4bRd7swFEA= github.com/txthinking/runnergroup v0.0.0-20210608031112-152c7c4432bf/go.mod h1:CLUSJbazqETbaR+i0YAhXBICV9TrKH93pziccMhmhpM= github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU= github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI= diff --git a/network/v2/base_http_test.go b/network/v2/base_http_test.go new file mode 100644 index 0000000..1d322bb --- /dev/null +++ b/network/v2/base_http_test.go @@ -0,0 +1,49 @@ +package network_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/9seconds/mtg/v2/network/v2" + "github.com/stretchr/testify/suite" +) + +type BaseHTTPTestSuite struct { + suite.Suite + + http *httptest.Server + client *http.Client +} + +func (suite *BaseHTTPTestSuite) SetupSuite() { + suite.http = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(r.Header.Get("User-Agent"))) + })) +} + +func (suite *BaseHTTPTestSuite) SetupTest() { + suite.client = network.New(nil, "mtg/1", 0, 0, 0).MakeHTTPClient(nil) +} + +func (suite *BaseHTTPTestSuite) TestGet() { + resp, err := suite.client.Get(suite.http.URL) + suite.NoError(err) + + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + suite.NoError(err) + suite.Equal("mtg/1", string(data)) +} + +func (suite *BaseHTTPTestSuite) TearDownSuite() { + suite.http.Close() +} + +func TestBaseHTTP(t *testing.T) { + t.Parallel() + suite.Run(t, &BaseHTTPTestSuite{}) +} diff --git a/network/v2/base_network_test.go b/network/v2/base_network_test.go new file mode 100644 index 0000000..f9729e4 --- /dev/null +++ b/network/v2/base_network_test.go @@ -0,0 +1,83 @@ +package network_test + +import ( + "context" + "testing" + + "github.com/9seconds/mtg/v2/network/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type BaseNetworkTestSuite struct { + EchoServerTestSuite + + net network.Network +} + +func (suite *BaseNetworkTestSuite) SetupSuite() { + suite.EchoServerTestSuite.SetupSuite() + + suite.net = network.New(nil, "agent", 0, 0, 0) +} + +func (suite *BaseNetworkTestSuite) TestDialUnknownNetwork() { + testData := []string{ + "udp", + "udp4", + "udp6", + "unix", + } + + for _, name := range testData { + suite.T().Run(name, func(t *testing.T) { + _, err := suite.net.Dial(name, suite.EchoServerAddr()) + assert.Error(t, err) + }) + } +} + +func (suite *BaseNetworkTestSuite) TestDial() { + conn, err := suite.net.Dial("tcp4", suite.EchoServerAddr()) + suite.NoError(err) + + buf := []byte{1, 2, 3, 4, 5} + n, err := conn.Write(buf) + suite.Equal(5, n) + suite.NoError(err) + + another := make([]byte, len(buf)) + n, err = conn.Read(another) + suite.NoError(err) + suite.Equal(len(another), n) + suite.Equal(buf, another) +} + +func (suite *BaseNetworkTestSuite) TestDialContextOk() { + conn, err := suite.net.DialContext(context.Background(), "tcp4", suite.EchoServerAddr()) + suite.NoError(err) + + buf := []byte{1, 2, 3, 4, 5} + n, err := conn.Write(buf) + suite.Equal(5, n) + suite.NoError(err) + + another := make([]byte, len(buf)) + n, err = conn.Read(another) + suite.NoError(err) + suite.Equal(len(another), n) + suite.Equal(buf, another) +} + +func (suite *BaseNetworkTestSuite) TestDialContextClosed() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := suite.net.DialContext(ctx, "tcp4", suite.EchoServerAddr()) + suite.ErrorIs(err, ctx.Err()) +} + +func TestNetworkBase(t *testing.T) { + t.Parallel() + suite.Run(t, &BaseNetworkTestSuite{}) +} diff --git a/network/v2/echo_server_test.go b/network/v2/echo_server_test.go new file mode 100644 index 0000000..cfcead8 --- /dev/null +++ b/network/v2/echo_server_test.go @@ -0,0 +1,106 @@ +package network_test + +import ( + "context" + "io" + "net" + "sync" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type EchoServer struct { + wg sync.WaitGroup + ctx context.Context + ctxCancel context.CancelFunc + listener net.Listener +} + +func (e *EchoServer) Run() { + e.wg.Go(func() { + <-e.ctx.Done() + e.listener.Close() + }) + + e.wg.Go(func() { + for { + conn, err := e.listener.Accept() + if err != nil { + return + } + + e.wg.Go(func() { + <-e.ctx.Done() + conn.Close() + }) + e.wg.Go(func() { + e.process(conn) + }) + } + }) +} + +func (e *EchoServer) Stop() { + e.ctxCancel() + e.wg.Wait() +} + +func (e *EchoServer) Addr() string { + return e.listener.Addr().String() +} + +func (e *EchoServer) process(conn io.ReadWriter) { + buf := [4096]byte{} + + for { + select { + case <-e.ctx.Done(): + return + default: + } + + n, err := conn.Read(buf[:]) + if err != nil { + return + } + + select { + case <-e.ctx.Done(): + return + default: + } + + if _, err = conn.Write(buf[:n]); err != nil { + return + } + } +} + +type EchoServerTestSuite struct { + suite.Suite + + echoServer *EchoServer +} + +func (suite *EchoServerTestSuite) SetupSuite() { + ctx, cancel := context.WithCancel(context.Background()) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(suite.T(), err) + + suite.echoServer = &EchoServer{ + ctx: ctx, + ctxCancel: cancel, + listener: listener, + } + suite.echoServer.Run() +} + +func (suite *EchoServerTestSuite) TearDownSuite() { + suite.echoServer.Stop() +} + +func (suite *EchoServerTestSuite) EchoServerAddr() string { + return suite.echoServer.Addr() +} diff --git a/network/v2/http.go b/network/v2/http.go new file mode 100644 index 0000000..58e6a5e --- /dev/null +++ b/network/v2/http.go @@ -0,0 +1,14 @@ +package network + +import "net/http" + +type networkHTTPTransport struct { + userAgent string + next http.RoundTripper +} + +func (n networkHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", n.userAgent) + + return n.next.RoundTrip(req) //nolint: wrapcheck +} diff --git a/network/v2/init.go b/network/v2/init.go new file mode 100644 index 0000000..7cc4302 --- /dev/null +++ b/network/v2/init.go @@ -0,0 +1,45 @@ +// Network contains a default implementation of the network. +// +// Please see [mtglib.Network] interface to get some basic idea behind this +// abstraction. +// +// This implementation is more simple that v1 because life shows that all +// this complexity, especially around circuit breakers and DoH is not really +// required. There is no chance that if DNS address is spoofed, that real +// IP would work as expected. +package network + +import ( + "errors" + "net" + "time" + + "github.com/9seconds/mtg/v2/mtglib" +) + +const ( + // DefaultTimeout is a default timeout for establishing TCP connection. + DefaultTimeout = 10 * time.Second + + // DefaultHTTPTimeout defines a default timeout for making HTTP request. + DefaultHTTPTimeout = 10 * time.Second + + // DefaultIdleTimeout defines a timeout for idle HTTP connections + DefaultIdleTimeout = time.Minute + + // DefaultTCPKeepAlivePeriod defines a time period between 2 consecuitive + // probes. + DefaultTCPKeepAlivePeriod = 10 * time.Second + + // tcpLingerTimeout defines a number of seconds to wait for sending + // unacknowledged data. + tcpLingerTimeout = 1 +) + +var ErrCannotDial = errors.New("cannot dial to any address") + +type Network interface { + mtglib.Network + + NativeDialer() *net.Dialer +} diff --git a/network/v2/multi_network.go b/network/v2/multi_network.go new file mode 100644 index 0000000..45e501c --- /dev/null +++ b/network/v2/multi_network.go @@ -0,0 +1,70 @@ +package network + +import ( + "context" + "errors" + "math/rand" + "net" + "net/http" + + "github.com/9seconds/mtg/v2/essentials" +) + +type multiNetwork struct { + networks []Network +} + +func (m multiNetwork) Dial(network, address string) (essentials.Conn, error) { + return m.DialContext(context.Background(), network, address) +} + +func (m multiNetwork) DialContext(ctx context.Context, network, address string) (essentials.Conn, error) { + networks := m.networks + + if len(networks) > 1 { + networks = make([]Network, len(m.networks)) + copy(networks, m.networks) + + rand.Shuffle(len(m.networks), func(i, j int) { + networks[i], networks[j] = networks[j], networks[i] + }) + } + + errs := make([]error, 1, len(networks)+1) + errs[0] = ErrCannotDial + + for _, ntw := range networks { + conn, err := ntw.DialContext(ctx, network, address) + if err == nil { + return conn, nil + } + + errs = append(errs, err) + } + + return nil, errors.Join(errs...) +} + +func (m multiNetwork) NativeDialer() *net.Dialer { + return m.networks[0].NativeDialer() +} + +func (m multiNetwork) MakeHTTPClient( + dialFunc func(context.Context, string, string) (essentials.Conn, error), +) *http.Client { + if dialFunc == nil { + dialFunc = m.DialContext + } + + return m.networks[0].MakeHTTPClient(dialFunc) +} + +func Join(networks ...Network) (Network, error) { + if len(networks) == 0 { + return nil, errors.New("cannot join no networks") + } + + return multiNetwork{ + networks: networks, + }, nil +} diff --git a/network/v2/network.go b/network/v2/network.go new file mode 100644 index 0000000..9921f8b --- /dev/null +++ b/network/v2/network.go @@ -0,0 +1,88 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/9seconds/mtg/v2/essentials" +) + +type network struct { + net.Dialer + + httpTimeout time.Duration + idleTimeout time.Duration + userAgent string +} + +func (n *network) Dial(network, address string) (essentials.Conn, error) { + return n.DialContext(context.Background(), network, address) +} + +func (n *network) DialContext(ctx context.Context, network, address string) (essentials.Conn, error) { + switch network { + case "tcp", "tcp4", "tcp6": + default: + return nil, fmt.Errorf("unsupported network %s", network) + } + + conn, err := n.Dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + + tcpConn := conn.(*net.TCPConn) + + return tcpConn, setCommonSocketOptions(tcpConn) +} + +func (n *network) MakeHTTPClient( + dialFunc func(context.Context, string, string) (essentials.Conn, error), +) *http.Client { + if dialFunc == nil { + dialFunc = n.DialContext + } + + return &http.Client{ + Timeout: n.httpTimeout, + Transport: networkHTTPTransport{ + userAgent: n.userAgent, + next: &http.Transport{ + IdleConnTimeout: n.idleTimeout, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return dialFunc(ctx, network, address) + }, + }, + }, + } +} + +func (n *network) NativeDialer() *net.Dialer { + return &n.Dialer +} + +func New( + dnsResolver *net.Resolver, + userAgent string, + tcpTimeout, + httpTimeout, + idleTimeout time.Duration, +) Network { + if dnsResolver == nil { + dnsResolver = net.DefaultResolver + } + + return &network{ + Dialer: net.Dialer{ + Timeout: tcpTimeout, + Resolver: dnsResolver, + FallbackDelay: -1, + }, + userAgent: userAgent, + idleTimeout: idleTimeout, + httpTimeout: httpTimeout, + } +} diff --git a/network/v2/proxy_network.go b/network/v2/proxy_network.go new file mode 100644 index 0000000..c27162d --- /dev/null +++ b/network/v2/proxy_network.go @@ -0,0 +1,36 @@ +package network + +import ( + "context" + "fmt" + "net/url" + + "github.com/9seconds/mtg/v2/essentials" + "golang.org/x/net/proxy" +) + +type proxyNetwork struct { + Network + client proxy.ContextDialer +} + +func (p proxyNetwork) DialContext(ctx context.Context, network, address string) (essentials.Conn, error) { + conn, err := p.client.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + + return essentials.WrapNetConn(conn), nil +} + +func NewProxyNetwork(base Network, proxyURL *url.URL) (*proxyNetwork, error) { + socks, err := proxy.FromURL(proxyURL, base.NativeDialer()) + if err != nil { + return nil, fmt.Errorf("cannot build proxy dialer: %w", err) + } + + return &proxyNetwork{ + Network: base, + client: socks.(proxy.ContextDialer), + }, nil +} diff --git a/network/v2/sockopts.go b/network/v2/sockopts.go new file mode 100644 index 0000000..378db00 --- /dev/null +++ b/network/v2/sockopts.go @@ -0,0 +1,27 @@ +package network + +import ( + "fmt" + "net" +) + +func setCommonSocketOptions(conn *net.TCPConn) error { + if err := conn.SetKeepAlivePeriod(DefaultTCPKeepAlivePeriod); err != nil { + return fmt.Errorf("cannot set time period of TCP keepalive probes: %w", err) + } + + if err := conn.SetLinger(tcpLingerTimeout); err != nil { + return fmt.Errorf("cannot set TCP linger timeout: %w", err) + } + + rawConn, err := conn.SyscallConn() + if err != nil { + return fmt.Errorf("cannot get underlying raw connection: %w", err) + } + + if err := setSocketReuseAddrPort(rawConn); err != nil { + return fmt.Errorf("cannot setup SO_REUSEADDR/PORT: %w", err) + } + + return nil +} diff --git a/network/v2/sockopts_unix.go b/network/v2/sockopts_unix.go new file mode 100644 index 0000000..75df3af --- /dev/null +++ b/network/v2/sockopts_unix.go @@ -0,0 +1,31 @@ +//go:build !windows +// +build !windows + +package network + +import ( + "fmt" + "syscall" + + "golang.org/x/sys/unix" +) + +func setSocketReuseAddrPort(conn syscall.RawConn) error { + var err error + + conn.Control(func(fd uintptr) { //nolint: errcheck + err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) + if err != nil { + err = fmt.Errorf("cannot set SO_REUSEADDR: %w", err) + + return + } + + err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1) + if err != nil { + err = fmt.Errorf("cannot set SO_REUSEPORT: %w", err) + } + }) + + return err +} diff --git a/network/v2/sockopts_windows.go b/network/v2/sockopts_windows.go new file mode 100644 index 0000000..32a702a --- /dev/null +++ b/network/v2/sockopts_windows.go @@ -0,0 +1,10 @@ +//go:build windows +// +build windows + +package network + +import "syscall" + +func setSocketReuseAddrPort(conn syscall.RawConn) error { + return nil +} diff --git a/network/v2/socks_proxy_test.go b/network/v2/socks_proxy_test.go new file mode 100644 index 0000000..55262ac --- /dev/null +++ b/network/v2/socks_proxy_test.go @@ -0,0 +1,127 @@ +package network_test + +import ( + "net" + "net/url" + "sync" + "testing" + + "github.com/9seconds/mtg/v2/network/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/things-go/go-socks5" +) + +type SocksProxyTestSuite struct { + EchoServerTestSuite + + wg sync.WaitGroup + baseNetwork network.Network + + noAuthURL *url.URL + authURL *url.URL + + noAuthListener net.Listener + authListener net.Listener + + noAuthServer *socks5.Server + authServer *socks5.Server +} + +func (suite *SocksProxyTestSuite) SetupSuite() { + suite.EchoServerTestSuite.SetupSuite() + + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(suite.T(), err) + suite.noAuthListener = listener + + listener, err = net.Listen("tcp4", "127.0.0.1:0") + require.NoError(suite.T(), err) + suite.authListener = listener + + suite.noAuthServer = socks5.NewServer() + suite.wg.Go(func() { + suite.noAuthServer.Serve(suite.noAuthListener) + }) + + suite.authServer = socks5.NewServer( + socks5.WithAuthMethods([]socks5.Authenticator{ + socks5.UserPassAuthenticator{ + Credentials: socks5.StaticCredentials{ + "user": "pass", + }, + }, + })) + suite.wg.Go(func() { + suite.authServer.Serve(suite.authListener) + }) + + parsed, err := url.Parse("socks5://" + suite.noAuthListener.Addr().String()) + require.NoError(suite.T(), err) + suite.noAuthURL = parsed + + parsed, err = url.Parse("socks5://user:pass@" + suite.authListener.Addr().String()) + require.NoError(suite.T(), err) + suite.authURL = parsed + + suite.baseNetwork = network.New(nil, "mtg", 0, 0, 0) +} + +func (suite *SocksProxyTestSuite) TestIncorrectSchema() { + parsed, err := url.Parse("http://hello") + suite.NoError(err) + + _, err = network.NewProxyNetwork(suite.baseNetwork, parsed) + suite.Error(err) +} + +func (suite *SocksProxyTestSuite) TestRead() { + testData := map[string][]*url.URL{ + "noAuth": {suite.noAuthURL}, + "auth": {suite.authURL}, + "both": {suite.noAuthURL, suite.authURL}, + } + + for name, proxies := range testData { + suite.T().Run(name, func(t *testing.T) { + proxyNetworks := []network.Network{} + + for _, u := range proxies { + value, err := network.NewProxyNetwork(suite.baseNetwork, u) + assert.NoError(t, err) + proxyNetworks = append(proxyNetworks, value) + } + + netw, err := network.Join(proxyNetworks...) + assert.NoError(t, err) + + conn, err := netw.Dial("tcp4", suite.EchoServerAddr()) + assert.NoError(t, err) + + data := []byte{1, 2, 3} + n, err := conn.Write(data) + assert.NoError(t, err) + assert.Equal(t, len(data), n) + + toRead := []byte{1, 2, 3, 4, 5} + n, err = conn.Read(toRead) + assert.NoError(t, err) + assert.Equal(t, len(data), n) + assert.Equal(t, data, toRead[:n]) + assert.NotEqual(t, data, toRead) + }) + } +} + +func (suite *SocksProxyTestSuite) TearDownSuite() { + suite.noAuthListener.Close() + suite.authListener.Close() + suite.wg.Wait() + suite.EchoServerTestSuite.TearDownSuite() +} + +func TestSocksProxy(t *testing.T) { + t.Parallel() + suite.Run(t, &SocksProxyTestSuite{}) +} From 11512915357420b3912ce03b87a5a59974d851e9 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Fri, 27 Feb 2026 09:12:24 +0100 Subject: [PATCH 2/5] Use network v2 instead of network v1 --- go.mod | 4 +-- go.sum | 4 +-- internal/cli/run_proxy.go | 54 +++++++++++++------------------ internal/config/type_proxy_url.go | 12 ++++--- network/v2/base_http_test.go | 4 +-- network/v2/echo_server_test.go | 4 +-- network/v2/socks_proxy_test.go | 8 ++--- 7 files changed, 43 insertions(+), 47 deletions(-) diff --git a/go.mod b/go.mod index 96d1216..55ef3cf 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b golang.org/x/crypto v0.48.0 - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.51.0 golang.org/x/sys v0.41.0 google.golang.org/protobuf v1.36.11 // indirect ) @@ -29,6 +29,7 @@ require ( require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/pires/go-proxyproto v0.11.0 + github.com/things-go/go-socks5 v0.1.0 github.com/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e github.com/yl2chen/cidranger v1.0.2 ) @@ -46,7 +47,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/things-go/go-socks5 v0.1.0 // indirect github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index a9cdd4a..7d99c17 100644 --- a/go.sum +++ b/go.sum @@ -117,8 +117,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/cli/run_proxy.go b/internal/cli/run_proxy.go index 773f1a8..abd3e73 100644 --- a/internal/cli/run_proxy.go +++ b/internal/cli/run_proxy.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net" - "net/url" "os" "github.com/9seconds/mtg/v2/antireplay" @@ -16,7 +15,7 @@ import ( "github.com/9seconds/mtg/v2/ipblocklist/files" "github.com/9seconds/mtg/v2/logger" "github.com/9seconds/mtg/v2/mtglib" - "github.com/9seconds/mtg/v2/network" + "github.com/9seconds/mtg/v2/network/v2" "github.com/9seconds/mtg/v2/stats" "github.com/pires/go-proxyproto" "github.com/rs/zerolog" @@ -40,43 +39,36 @@ func makeLogger(conf *config.Config) mtglib.Logger { } func makeNetwork(conf *config.Config, version string) (mtglib.Network, error) { - tcpTimeout := conf.Network.Timeout.TCP.Get(network.DefaultTimeout) - httpTimeout := conf.Network.Timeout.HTTP.Get(network.DefaultHTTPTimeout) - dohIP := conf.Network.DOHIP.Get(net.ParseIP(network.DefaultDOHHostname)).String() - userAgent := "mtg/" + version + base := network.New( + nil, + "mtg/"+version, + conf.Network.Timeout.TCP.Get(0), + conf.Network.Timeout.HTTP.Get(0), + conf.Network.Timeout.Idle.Get(0), + ) - baseDialer, err := network.NewDefaultDialer(tcpTimeout, 0) - if err != nil { - return nil, fmt.Errorf("cannot build a default dialer: %w", err) - } - - if len(conf.Network.Proxies) == 0 { - return network.NewNetwork(baseDialer, userAgent, dohIP, httpTimeout) //nolint: wrapcheck - } - - proxyURLs := make([]*url.URL, 0, len(conf.Network.Proxies)) - - for _, v := range conf.Network.Proxies { - if value := v.Get(nil); value != nil { - proxyURLs = append(proxyURLs, value) - } - } - - if len(proxyURLs) == 1 { - socksDialer, err := network.NewSocks5Dialer(baseDialer, proxyURLs[0]) + proxyDialers := make([]network.Network, len(conf.Network.Proxies)) + for idx, v := range conf.Network.Proxies { + value, err := network.NewProxyNetwork(base, v.Get(nil)) if err != nil { - return nil, fmt.Errorf("cannot build socks5 dialer: %w", err) + return nil, fmt.Errorf("cannot use %v for proxy url: %w", v.Get(nil), err) } - - return network.NewNetwork(socksDialer, userAgent, dohIP, httpTimeout) //nolint: wrapcheck + proxyDialers[idx] = value } - socksDialer, err := network.NewLoadBalancedSocks5Dialer(baseDialer, proxyURLs) + switch len(proxyDialers) { + case 0: + return base, nil + case 1: + return proxyDialers[0], nil + } + + value, err := network.Join(proxyDialers...) if err != nil { - return nil, fmt.Errorf("cannot build socks5 dialer: %w", err) + panic(err) } - return network.NewNetwork(socksDialer, userAgent, dohIP, httpTimeout) //nolint: wrapcheck + return value, nil } func makeAntiReplayCache(conf *config.Config) mtglib.AntiReplayCache { diff --git a/internal/config/type_proxy_url.go b/internal/config/type_proxy_url.go index f3d0231..0e4a418 100644 --- a/internal/config/type_proxy_url.go +++ b/internal/config/type_proxy_url.go @@ -15,20 +15,24 @@ type TypeProxyURL struct { func (t *TypeProxyURL) Set(value string) error { parsedURL, err := url.Parse(value) if err != nil { - return fmt.Errorf("value is not corect URL (%s): %w", value, err) + return fmt.Errorf("value is not correct URL (%s): %w", value, err) } if parsedURL.Host == "" { return fmt.Errorf("url has to have a schema: %s", value) } - if parsedURL.Scheme != "socks5" { + switch parsedURL.Scheme { + case "socks5", "socks5h": + default: return fmt.Errorf("unsupported schema: %s", parsedURL.Scheme) } if _, _, err := net.SplitHostPort(parsedURL.Host); err != nil { - parsedURL.Host = net.JoinHostPort(parsedURL.Host, - typeProxyURLDefaultSOCKS5Port) + parsedURL.Host = net.JoinHostPort( + parsedURL.Host, + typeProxyURLDefaultSOCKS5Port, + ) } t.Value = parsedURL diff --git a/network/v2/base_http_test.go b/network/v2/base_http_test.go index 1d322bb..9047782 100644 --- a/network/v2/base_http_test.go +++ b/network/v2/base_http_test.go @@ -20,7 +20,7 @@ type BaseHTTPTestSuite struct { func (suite *BaseHTTPTestSuite) SetupSuite() { suite.http = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte(r.Header.Get("User-Agent"))) + w.Write([]byte(r.Header.Get("User-Agent"))) //nolint: errcheck })) } @@ -32,7 +32,7 @@ func (suite *BaseHTTPTestSuite) TestGet() { resp, err := suite.client.Get(suite.http.URL) suite.NoError(err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint: errcheck data, err := io.ReadAll(resp.Body) suite.NoError(err) diff --git a/network/v2/echo_server_test.go b/network/v2/echo_server_test.go index cfcead8..4561c2e 100644 --- a/network/v2/echo_server_test.go +++ b/network/v2/echo_server_test.go @@ -20,7 +20,7 @@ type EchoServer struct { func (e *EchoServer) Run() { e.wg.Go(func() { <-e.ctx.Done() - e.listener.Close() + e.listener.Close() //nolint: errcheck }) e.wg.Go(func() { @@ -32,7 +32,7 @@ func (e *EchoServer) Run() { e.wg.Go(func() { <-e.ctx.Done() - conn.Close() + conn.Close() //nolint: errcheck }) e.wg.Go(func() { e.process(conn) diff --git a/network/v2/socks_proxy_test.go b/network/v2/socks_proxy_test.go index 55262ac..cc02dda 100644 --- a/network/v2/socks_proxy_test.go +++ b/network/v2/socks_proxy_test.go @@ -42,7 +42,7 @@ func (suite *SocksProxyTestSuite) SetupSuite() { suite.noAuthServer = socks5.NewServer() suite.wg.Go(func() { - suite.noAuthServer.Serve(suite.noAuthListener) + suite.noAuthServer.Serve(suite.noAuthListener) //nolint: errcheck }) suite.authServer = socks5.NewServer( @@ -54,7 +54,7 @@ func (suite *SocksProxyTestSuite) SetupSuite() { }, })) suite.wg.Go(func() { - suite.authServer.Serve(suite.authListener) + suite.authServer.Serve(suite.authListener) //nolint: errcheck }) parsed, err := url.Parse("socks5://" + suite.noAuthListener.Addr().String()) @@ -115,8 +115,8 @@ func (suite *SocksProxyTestSuite) TestRead() { } func (suite *SocksProxyTestSuite) TearDownSuite() { - suite.noAuthListener.Close() - suite.authListener.Close() + suite.noAuthListener.Close() //nolint: errcheck + suite.authListener.Close() //nolint: errcheck suite.wg.Wait() suite.EchoServerTestSuite.TearDownSuite() } From 317d7380cb557f959a61cab8a5f8fc04c46bfe81 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Fri, 27 Feb 2026 15:33:29 +0100 Subject: [PATCH 3/5] Add support for custom DNS resolvers --- go.mod | 1 + go.sum | 2 ++ network/v2/dns.go | 53 ++++++++++++++++++++++++++++++++ network/v2/dns_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 network/v2/dns.go create mode 100644 network/v2/dns_test.go diff --git a/go.mod b/go.mod index 55ef3cf..cb0e935 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( ) require ( + github.com/ncruces/go-dns v1.3.2 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pires/go-proxyproto v0.11.0 github.com/things-go/go-socks5 v0.1.0 diff --git a/go.sum b/go.sum index 7d99c17..b61ae52 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/miekg/dns v1.1.51 h1:0+Xg7vObnhrz/4ZCZcZh7zPXlmU0aveS2HDBd0m0qSo= github.com/miekg/dns v1.1.51/go.mod h1:2Z9d3CP1LQWihRZUf29mQ19yDThaI4DAYzte2CaQW5c= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-dns v1.3.2 h1:kBLuUZBgkQ4qF4WDXZRQ4rG0Gk6sLVJQ5tESkWrxUa0= +github.com/ncruces/go-dns v1.3.2/go.mod h1:tuzixNY8PY/M7yUzcvRbUaeLs3ifIdydpi5H2bfRU+s= github.com/panjf2000/ants/v2 v2.11.5 h1:a7LMnMEeux/ebqTux140tRiaqcFTV0q2bEHF03nl6Rg= github.com/panjf2000/ants/v2 v2.11.5/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= diff --git a/network/v2/dns.go b/network/v2/dns.go new file mode 100644 index 0000000..0728609 --- /dev/null +++ b/network/v2/dns.go @@ -0,0 +1,53 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/url" + "time" + + "github.com/ncruces/go-dns" +) + +var dnsCacheOptions = []dns.CacheOption{ + dns.MaxCacheEntries(dns.DefaultMaxCacheEntries), + dns.MaxCacheTTL(time.Hour), + dns.NegativeCache(false), +} + +func GetDNS(u *url.URL) (*net.Resolver, error) { + if u == nil { + return dns.NewCachingResolver(nil, dnsCacheOptions...), nil + } + + switch u.Scheme { + case "tls": + return dns.NewDoTResolver(u.Host, dns.DoTCache(dnsCacheOptions...)) + case "https": + if u.Path == "" { + u.Path = "/dns-query" + } + + return dns.NewDoHResolver(u.String(), dns.DoHCache(dnsCacheOptions...)) + case "udp", "": + default: + return nil, fmt.Errorf("unsupported DNS %v", u) + } + + port := u.Port() + if port == "" { + port = "53" + } + + hostport := net.JoinHostPort(u.Hostname(), port) + dialer := &net.Dialer{} + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + return dialer.DialContext(ctx, "udp", hostport) + }, + } + + return dns.NewCachingResolver(resolver, dnsCacheOptions...), nil +} diff --git a/network/v2/dns_test.go b/network/v2/dns_test.go new file mode 100644 index 0000000..7a05eca --- /dev/null +++ b/network/v2/dns_test.go @@ -0,0 +1,68 @@ +package network_test + +import ( + "context" + "net" + "net/url" + "testing" + "time" + + "github.com/9seconds/mtg/v2/network/v2" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type DNSTestSuite struct { + suite.Suite +} + +func (suite *DNSTestSuite) TestDefault() { + resolver, err := network.GetDNS(nil) + suite.NoError(err) + suite.doTest(resolver) +} + +func (suite *DNSTestSuite) TestDoH() { + for _, addr := range []string{"1.1.1.1", "cloudflare-dns.com"} { + suite.Run(addr, func() { + u, err := url.Parse("https://" + addr) + require.NoError(suite.T(), err) + + resolver, err := network.GetDNS(u) + suite.NoError(err) + suite.doTest(resolver) + }) + } +} + +func (suite *DNSTestSuite) TestDoT() { + u, err := url.Parse("tls://dns.google") + require.NoError(suite.T(), err) + + resolver, err := network.GetDNS(u) + suite.NoError(err) + suite.doTest(resolver) +} + +func (suite *DNSTestSuite) TestUDP() { + u, err := url.Parse("8.8.8.8") + require.NoError(suite.T(), err) + + resolver, err := network.GetDNS(u) + suite.NoError(err) + suite.doTest(resolver) +} + +func (suite *DNSTestSuite) doTest(resolver *net.Resolver) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ips, err := resolver.LookupIP(ctx, "ip4", "dns.google") + suite.NoError(err) + suite.Greater(len(ips), 0) +} + +func TestGetDNS(t *testing.T) { + t.Parallel() + suite.Run(t, &DNSTestSuite{}) +} From 897e6bf505294c779bb4933910cace5b4b10ac62 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Fri, 27 Feb 2026 15:33:49 +0100 Subject: [PATCH 4/5] Propagate DNS setting to configuration --- example.config.toml | 41 ++++++---- internal/config/config.go | 12 +++ internal/config/parse.go | 1 + internal/config/type_dns_uri.go | 69 ++++++++++++++++ internal/config/type_dns_uri_test.go | 117 +++++++++++++++++++++++++++ network/v2/dns.go | 8 ++ 6 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 internal/config/type_dns_uri.go create mode 100644 internal/config/type_dns_uri_test.go diff --git a/example.config.toml b/example.config.toml index 0e40c3f..4cd84d0 100644 --- a/example.config.toml +++ b/example.config.toml @@ -134,8 +134,33 @@ allow-fallback-on-unknown-dc = false # it has to access. # # By default we use Cloudflare. +# +# DEPRECATED option: +# If dns option is specified, it will be used instead doh-ip = "1.1.1.1" +# Starting from mtg v2.1.12 we have changed a configuration for DNS. Now it +# supports DNS-over-HTTPS, DNS-over-TLS, custom UDP resolver and system +# resolver. +# +# Here is how to define DNS-over-HTTPS: +# - https://1.1.1.1 +# - https://1.1.1.1/dns-query +# - https://cloudflare-dns.com/dns-query +# - https://cloudflare-dns.com +# +# Here is how to define DNS-over-TLS: +# - tls://1.1.1.1 +# - tls://cloudflare-dns.com +# +# Here is how to define a custom UDP resolver (we support only IPs here) +# - 1.1.1.1 +# - udp://1.1.1.1 +# +# If you set it to empty string, default resolver will be used. +# But please comment out doh-ip +dns = "https://1.1.1.1" + # mtg can work via proxies (for now, we support only socks5). Proxy # configuration is done via list. So, you can specify many proxies # there. @@ -149,25 +174,13 @@ doh-ip = "1.1.1.1" # # Proxy configuration is done via ordinary URI schema: # -# socks5://user:password@host:port?open_threshold=5&half_open_timeout=1m&reset_failures_timeout=10s +# socks5://user:password@host:port # # Only socks5 proxy is used. user/password is optional. As you can # see, you can specify some parameters in GET query. These parameters # configure circuit breaker. -# -# open_threshold means a number of errors which should happen so we stop -# use a proxy. -# -# half_open_timeout means a time period (in Golang duration notation) -# after which we can retry with this proxy -# -# reset_failures_timeout means a time period when we flush out errors -# when circuit breaker in closed state. -# -# Please see https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker -# on details about circuit breakers. proxies = [ - # "socks5://user:password@host:port?open_threshold=5&half_open_timeout=1m&reset_failures_timeout=10s" + # "socks5://user:password@host:port" ] # network timeouts define different settings for timeouts. tcp timeout diff --git a/internal/config/config.go b/internal/config/config.go index 15a76ab..e3d8bd2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net" + "net/url" "github.com/9seconds/mtg/v2/mtglib" ) @@ -56,6 +57,7 @@ type Config struct { Idle TypeDuration `json:"idle"` } `json:"timeout"` DOHIP TypeIP `json:"dohIp"` + DNS TypeDNSURI `json:"dns"` Proxies []TypeProxyURL `json:"proxies"` } `json:"network"` Stats struct { @@ -76,6 +78,16 @@ type Config struct { } `json:"stats"` } +func (c *Config) GetDNS() *url.URL { + var dohURL *url.URL + + if dohIP := c.Network.DOHIP.Get(nil); dohIP != nil { + dohURL, _ = url.Parse("https://" + dohIP.String()) + } + + return c.Network.DNS.Get(dohURL) +} + func (c *Config) GetDomainFrontingPort(defaultValue uint) uint { if port := c.DomainFronting.Port.Get(0); port != 0 { return port diff --git a/internal/config/parse.go b/internal/config/parse.go index bb5ccce..1186769 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -52,6 +52,7 @@ type tomlConfig struct { Idle string `toml:"idle" json:"idle,omitempty"` } `toml:"timeout" json:"timeout,omitempty"` DOHIP string `toml:"doh-ip" json:"dohIp,omitempty"` + DNS string `toml:"dns" json:"dns,omitempty"` Proxies []string `toml:"proxies" json:"proxies,omitempty"` } `toml:"network" json:"network,omitempty"` Stats struct { diff --git a/internal/config/type_dns_uri.go b/internal/config/type_dns_uri.go new file mode 100644 index 0000000..eeb09d2 --- /dev/null +++ b/internal/config/type_dns_uri.go @@ -0,0 +1,69 @@ +package config + +import ( + "fmt" + "net" + "net/url" +) + +type TypeDNSURI struct { + Value *url.URL +} + +func (t *TypeDNSURI) Set(value string) error { + parsed, err := url.Parse(value) + if err != nil { + return fmt.Errorf("value is not URI: %w", err) + } + + if parsed.Host == "" { + parsed.Host = parsed.Path + parsed.Path = "" + parsed.Scheme = "udp" + } + + switch parsed.Scheme { + case "https", "tls": + case "udp": + if ip := net.ParseIP(parsed.Hostname()); ip == nil { + return fmt.Errorf("simple DNS must IP address: %s", parsed.Hostname()) + } + default: + return fmt.Errorf("unsupported DNS type %s", parsed.Scheme) + } + + if parsed.Scheme != "https" && parsed.Path != "" { + return fmt.Errorf("path is supported only for DoH: %s", parsed) + } + + if parsed.User != nil { + return fmt.Errorf("used info is not supported: %s", parsed.User.String()) + } + + t.Value = parsed + + return nil +} + +func (t *TypeDNSURI) Get(defaultValue *url.URL) *url.URL { + if t.Value != nil { + return t.Value + } + + return defaultValue +} + +func (t *TypeDNSURI) UnmarshalText(data []byte) error { + return t.Set(string(data)) +} + +func (t TypeDNSURI) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +func (t TypeDNSURI) String() string { + if t.Value == nil { + return "" + } + return t.Value.String() +} diff --git a/internal/config/type_dns_uri_test.go b/internal/config/type_dns_uri_test.go new file mode 100644 index 0000000..939ca90 --- /dev/null +++ b/internal/config/type_dns_uri_test.go @@ -0,0 +1,117 @@ +package config_test + +import ( + "encoding/json" + "testing" + + "github.com/9seconds/mtg/v2/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type typeDNSURITestStruct struct { + Value config.TypeDNSURI `json:"value"` +} + +type TypeDNSURITestSuite struct { + suite.Suite +} + +func (suite *TypeDNSURITestSuite) TestUnmarshalFail() { + testData := []string{ + "xx", + "ppar", + "", + "dns://hahaha", + "udp://xcxxcv", + "udp://1.1.1.1/xcv", + "1.1.1.1/xxx", + "tls://dns/xx", + "tls://1.1.1.1/xx", + "https://user:password@1.1.1.1", + "tls://user:password@1.1.1.1", + "udp://user:password@1.1.1.1", + } + + for _, v := range testData { + data, err := json.Marshal(map[string]string{ + "value": v, + }) + suite.NoError(err) + + suite.T().Run(v, func(t *testing.T) { + assert.Error(t, json.Unmarshal(data, &typeDNSURITestStruct{})) + }) + } +} + +func (suite *TypeDNSURITestSuite) TestUnmarshalOk() { + testData := []string{ + "1.1.1.1", + "tls://1.1.1.1", + "tls://dns.google", + "https://1.1.1.1", + "https://1.1.1.1/dns-query", + "https://dns.google", + "https://dns.google/dns-query", + "udp://1.1.1.1", + } + + for _, v := range testData { + data, err := json.Marshal(map[string]string{ + "value": v, + }) + suite.NoError(err) + + suite.T().Run(v, func(t *testing.T) { + testStruct := &typeDNSURITestStruct{} + assert.NoError(t, json.Unmarshal(data, testStruct)) + if v == "1.1.1.1" { + v = "udp://" + v + } + assert.Equal(t, v, testStruct.Value.String()) + }) + } +} + +func (suite *TypeDNSURITestSuite) TestMarshalOk() { + testData := []string{ + "tls://1.1.1.1", + "tls://dns.google", + "https://1.1.1.1", + "https://1.1.1.1/dns-query", + } + + for _, v := range testData { + suite.T().Run(v, func(t *testing.T) { + testStruct := &typePreferIPTestStruct{ + Value: config.TypePreferIP{ + Value: v, + }, + } + + encodedJSON, err := json.Marshal(testStruct) + assert.NoError(t, err) + + expectedJSON, err := json.Marshal(map[string]string{ + "value": v, + }) + assert.NoError(t, err) + + assert.JSONEq(t, string(expectedJSON), string(encodedJSON)) + }) + } +} + +func (suite *TypeDNSURITestSuite) TestGet() { + value := config.TypeDNSURI{} + suite.Nil(value.Get(nil)) + + suite.NoError(value.Set("tls://1.1.1.1")) + suite.NotNil(value.Get(nil)) +} + +func TestDNSURI(t *testing.T) { + t.Parallel() + suite.Run(t, &TypeDNSURITestSuite{}) +} diff --git a/network/v2/dns.go b/network/v2/dns.go index 0728609..ddb0273 100644 --- a/network/v2/dns.go +++ b/network/v2/dns.go @@ -21,6 +21,14 @@ func GetDNS(u *url.URL) (*net.Resolver, error) { return dns.NewCachingResolver(nil, dnsCacheOptions...), nil } + if u.Scheme == "" { + u.Scheme = "udp" + } + if u.Scheme == "udp" && u.Host == "" { + u.Host = u.Path + u.Path = "" + } + switch u.Scheme { case "tls": return dns.NewDoTResolver(u.Host, dns.DoTCache(dnsCacheOptions...)) From 700417b1d047e5041f29eb99683b3a9e5d3eced8 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Fri, 27 Feb 2026 15:44:26 +0100 Subject: [PATCH 5/5] Integrate DNS resolver in our codebase --- internal/cli/run_proxy.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/cli/run_proxy.go b/internal/cli/run_proxy.go index abd3e73..b48fcbc 100644 --- a/internal/cli/run_proxy.go +++ b/internal/cli/run_proxy.go @@ -39,8 +39,13 @@ func makeLogger(conf *config.Config) mtglib.Logger { } func makeNetwork(conf *config.Config, version string) (mtglib.Network, error) { + resolver, err := network.GetDNS(conf.GetDNS()) + if err != nil { + return nil, fmt.Errorf("cannot create DNS resolver: %w", err) + } + base := network.New( - nil, + resolver, "mtg/"+version, conf.Network.Timeout.TCP.Get(0), conf.Network.Timeout.HTTP.Get(0),