Merge remote-tracking branch 'origin/master' into stable

This commit is contained in:
9seconds
2026-02-28 14:47:25 +01:00
24 changed files with 1108 additions and 49 deletions
+25
View File
@@ -24,3 +24,28 @@ type Conn interface {
CloseableReader CloseableReader
CloseableWriter 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}
}
+27 -14
View File
@@ -134,8 +134,33 @@ allow-fallback-on-unknown-dc = false
# it has to access. # it has to access.
# #
# By default we use Cloudflare. # By default we use Cloudflare.
#
# DEPRECATED option:
# If dns option is specified, it will be used instead
doh-ip = "1.1.1.1" 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 # mtg can work via proxies (for now, we support only socks5). Proxy
# configuration is done via list. So, you can specify many proxies # configuration is done via list. So, you can specify many proxies
# there. # there.
@@ -149,25 +174,13 @@ doh-ip = "1.1.1.1"
# #
# Proxy configuration is done via ordinary URI schema: # 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 # Only socks5 proxy is used. user/password is optional. As you can
# see, you can specify some parameters in GET query. These parameters # see, you can specify some parameters in GET query. These parameters
# configure circuit breaker. # 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 = [ 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 # network timeouts define different settings for timeouts. tcp timeout
+3 -1
View File
@@ -21,14 +21,16 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b
golang.org/x/crypto v0.48.0 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 golang.org/x/sys v0.41.0
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
require ( require (
github.com/ncruces/go-dns v1.3.2
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
github.com/pires/go-proxyproto v0.11.0 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/txthinking/socks5 v0.0.0-20251011041537-5c31f201a10e
github.com/yl2chen/cidranger v1.0.2 github.com/yl2chen/cidranger v1.0.2
) )
+6 -2
View File
@@ -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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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 h1:a7LMnMEeux/ebqTux140tRiaqcFTV0q2bEHF03nl6Rg=
github.com/panjf2000/ants/v2 v2.11.5/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= 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= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@@ -89,6 +91,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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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-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 h1:ArVM1jICfm7g4E4dBet+KHUFMLuxmj1Nxdp/tr3ByCU=
github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI= github.com/txthinking/runnergroup v0.0.0-20250224021307-5864ffeb65ae/go.mod h1:cldYm15/XHcGt7ndItnEWHwFZo7dinU+2QoyjfErhsI=
@@ -115,8 +119,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-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.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.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+24 -27
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"net/url"
"os" "os"
"github.com/9seconds/mtg/v2/antireplay" "github.com/9seconds/mtg/v2/antireplay"
@@ -16,7 +15,7 @@ import (
"github.com/9seconds/mtg/v2/ipblocklist/files" "github.com/9seconds/mtg/v2/ipblocklist/files"
"github.com/9seconds/mtg/v2/logger" "github.com/9seconds/mtg/v2/logger"
"github.com/9seconds/mtg/v2/mtglib" "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/9seconds/mtg/v2/stats"
"github.com/pires/go-proxyproto" "github.com/pires/go-proxyproto"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -40,43 +39,41 @@ func makeLogger(conf *config.Config) mtglib.Logger {
} }
func makeNetwork(conf *config.Config, version string) (mtglib.Network, error) { func makeNetwork(conf *config.Config, version string) (mtglib.Network, error) {
tcpTimeout := conf.Network.Timeout.TCP.Get(network.DefaultTimeout) resolver, err := network.GetDNS(conf.GetDNS())
httpTimeout := conf.Network.Timeout.HTTP.Get(network.DefaultHTTPTimeout)
dohIP := conf.Network.DOHIP.Get(net.ParseIP(network.DefaultDOHHostname)).String()
userAgent := "mtg/" + version
baseDialer, err := network.NewDefaultDialer(tcpTimeout, 0)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot build a default dialer: %w", err) return nil, fmt.Errorf("cannot create DNS resolver: %w", err)
} }
if len(conf.Network.Proxies) == 0 { base := network.New(
return network.NewNetwork(baseDialer, userAgent, dohIP, httpTimeout) //nolint: wrapcheck resolver,
} "mtg/"+version,
conf.Network.Timeout.TCP.Get(0),
conf.Network.Timeout.HTTP.Get(0),
conf.Network.Timeout.Idle.Get(0),
)
proxyURLs := make([]*url.URL, 0, len(conf.Network.Proxies)) proxyDialers := make([]network.Network, len(conf.Network.Proxies))
for idx, v := range conf.Network.Proxies {
for _, v := range conf.Network.Proxies { value, err := network.NewProxyNetwork(base, v.Get(nil))
if value := v.Get(nil); value != nil {
proxyURLs = append(proxyURLs, value)
}
}
if len(proxyURLs) == 1 {
socksDialer, err := network.NewSocks5Dialer(baseDialer, proxyURLs[0])
if err != 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)
}
proxyDialers[idx] = value
} }
return network.NewNetwork(socksDialer, userAgent, dohIP, httpTimeout) //nolint: wrapcheck switch len(proxyDialers) {
case 0:
return base, nil
case 1:
return proxyDialers[0], nil
} }
socksDialer, err := network.NewLoadBalancedSocks5Dialer(baseDialer, proxyURLs) value, err := network.Join(proxyDialers...)
if err != nil { 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 { func makeAntiReplayCache(conf *config.Config) mtglib.AntiReplayCache {
+12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"net/url"
"github.com/9seconds/mtg/v2/mtglib" "github.com/9seconds/mtg/v2/mtglib"
) )
@@ -56,6 +57,7 @@ type Config struct {
Idle TypeDuration `json:"idle"` Idle TypeDuration `json:"idle"`
} `json:"timeout"` } `json:"timeout"`
DOHIP TypeIP `json:"dohIp"` DOHIP TypeIP `json:"dohIp"`
DNS TypeDNSURI `json:"dns"`
Proxies []TypeProxyURL `json:"proxies"` Proxies []TypeProxyURL `json:"proxies"`
} `json:"network"` } `json:"network"`
Stats struct { Stats struct {
@@ -76,6 +78,16 @@ type Config struct {
} `json:"stats"` } `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 { func (c *Config) GetDomainFrontingPort(defaultValue uint) uint {
if port := c.DomainFronting.Port.Get(0); port != 0 { if port := c.DomainFronting.Port.Get(0); port != 0 {
return port return port
+1
View File
@@ -52,6 +52,7 @@ type tomlConfig struct {
Idle string `toml:"idle" json:"idle,omitempty"` Idle string `toml:"idle" json:"idle,omitempty"`
} `toml:"timeout" json:"timeout,omitempty"` } `toml:"timeout" json:"timeout,omitempty"`
DOHIP string `toml:"doh-ip" json:"dohIp,omitempty"` DOHIP string `toml:"doh-ip" json:"dohIp,omitempty"`
DNS string `toml:"dns" json:"dns,omitempty"`
Proxies []string `toml:"proxies" json:"proxies,omitempty"` Proxies []string `toml:"proxies" json:"proxies,omitempty"`
} `toml:"network" json:"network,omitempty"` } `toml:"network" json:"network,omitempty"`
Stats struct { Stats struct {
+69
View File
@@ -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()
}
+117
View File
@@ -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{})
}
+8 -4
View File
@@ -15,20 +15,24 @@ type TypeProxyURL struct {
func (t *TypeProxyURL) Set(value string) error { func (t *TypeProxyURL) Set(value string) error {
parsedURL, err := url.Parse(value) parsedURL, err := url.Parse(value)
if err != nil { 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 == "" { if parsedURL.Host == "" {
return fmt.Errorf("url has to have a schema: %s", value) 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) return fmt.Errorf("unsupported schema: %s", parsedURL.Scheme)
} }
if _, _, err := net.SplitHostPort(parsedURL.Host); err != nil { if _, _, err := net.SplitHostPort(parsedURL.Host); err != nil {
parsedURL.Host = net.JoinHostPort(parsedURL.Host, parsedURL.Host = net.JoinHostPort(
typeProxyURLDefaultSOCKS5Port) parsedURL.Host,
typeProxyURLDefaultSOCKS5Port,
)
} }
t.Value = parsedURL t.Value = parsedURL
+49
View File
@@ -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"))) //nolint: errcheck
}))
}
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() //nolint: errcheck
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{})
}
+83
View File
@@ -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{})
}
+61
View File
@@ -0,0 +1,61 @@
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
}
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...))
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
}
+68
View File
@@ -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{})
}
+106
View File
@@ -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() //nolint: errcheck
})
e.wg.Go(func() {
for {
conn, err := e.listener.Accept()
if err != nil {
return
}
e.wg.Go(func() {
<-e.ctx.Done()
conn.Close() //nolint: errcheck
})
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()
}
+14
View File
@@ -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
}
+45
View File
@@ -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
}
+70
View File
@@ -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
}
+88
View File
@@ -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,
}
}
+36
View File
@@ -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
}
+27
View File
@@ -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
}
+31
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
// +build windows
package network
import "syscall"
func setSocketReuseAddrPort(conn syscall.RawConn) error {
return nil
}
+127
View File
@@ -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) //nolint: errcheck
})
suite.authServer = socks5.NewServer(
socks5.WithAuthMethods([]socks5.Authenticator{
socks5.UserPassAuthenticator{
Credentials: socks5.StaticCredentials{
"user": "pass",
},
},
}))
suite.wg.Go(func() {
suite.authServer.Serve(suite.authListener) //nolint: errcheck
})
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() //nolint: errcheck
suite.authListener.Close() //nolint: errcheck
suite.wg.Wait()
suite.EchoServerTestSuite.TearDownSuite()
}
func TestSocksProxy(t *testing.T) {
t.Parallel()
suite.Run(t, &SocksProxyTestSuite{})
}