diff --git a/example.config.toml b/example.config.toml index b9293c270..cebfbd00e 100644 --- a/example.config.toml +++ b/example.config.toml @@ -112,11 +112,22 @@ allow-fallback-on-unknown-dc = false # required. [domain-fronting] # By default, mtg resolves the fronting hostname (from the secret) via DNS -# to establish a TCP connection. If DNS resolution of that hostname is blocked, -# you can specify an IP address to connect to directly. The hostname is still -# used for SNI in the TLS handshake. +# to establish a TCP connection. If that resolution is blocked, or loops +# back to this server (e.g. mtg sits behind an SNI router whose DNS points +# at itself), override the destination here. # -# default value is not set (DNS resolution is used). +# Use `host` — accepts a hostname or a literal IP. Hostnames are resolved +# at dial time, so a dual-stack DNS record can reach the right backend +# address family for IPv4 or IPv6 clients. +# +# The hostname from the secret is still used for SNI in the TLS handshake. +# +# default value is not set (the secret's hostname is used). +# host = "fronting-backend" + +# Deprecated: use `host`. If `ip` is set, mtg logs a warning at startup +# and ignores the value (domain-fronting falls back to the secret's +# hostname unless `host` is also set). # ip = "10.10.10.11" # FakeTLS uses domain fronting protection. So it needs to know a port to diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 48563bc1e..a44098a74 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -140,7 +140,18 @@ func (d *Doctor) checkDeprecatedConfig() bool { "when": "2.3.0", "old": "domain-fronting-ip", "old_section": "", - "new": "ip", + "new": "host", + "new_section": "domain-fronting", + }) + } + + if d.conf.DomainFronting.IP.Value != nil { + ok = false + tplWDeprecatedConfig.Execute(os.Stdout, map[string]string{ //nolint: errcheck + "when": "2.4.0", + "old": "ip", + "old_section": "domain-fronting", + "new": "host", "new_section": "domain-fronting", }) } @@ -298,8 +309,8 @@ func (d *Doctor) checkNetworkAddresses(ntw mtglib.Network, addresses []string) e func (d *Doctor) checkFrontingDomain(ntw mtglib.Network) bool { host := d.conf.Secret.Host - if ip := d.conf.GetDomainFrontingIP(nil); ip != "" { - host = ip + if override := d.conf.GetDomainFrontingHost(); override != "" { + host = override } port := d.conf.GetDomainFrontingPort(mtglib.DefaultDomainFrontingPort) diff --git a/internal/cli/run_proxy.go b/internal/cli/run_proxy.go index 5d9e63e98..f2543af2f 100644 --- a/internal/cli/run_proxy.go +++ b/internal/cli/run_proxy.go @@ -287,11 +287,23 @@ func warnSNIMismatch(conf *config.Config, ntw mtglib.Network, log mtglib.Logger) "DPI may detect and block the proxy. See 'mtg doctor' for details") } +func warnDeprecatedDomainFronting(conf *config.Config, log mtglib.Logger) { + if conf.DomainFrontingIP.Value != nil { + log.Warning(`config option "domain-fronting-ip" is deprecated and ignored; use "host" in [domain-fronting] instead`) + } + + if conf.DomainFronting.IP.Value != nil { + log.Warning(`config option "ip" in [domain-fronting] is deprecated and ignored; use "host" instead`) + } +} + func runProxy(conf *config.Config, version string) error { //nolint: funlen, cyclop logger := makeLogger(conf) logger.BindJSON("configuration", conf.String()).Debug("configuration") + warnDeprecatedDomainFronting(conf, logger) + eventStream, err := makeEventStream(conf, logger) if err != nil { return fmt.Errorf("cannot build event stream: %w", err) @@ -343,7 +355,7 @@ func runProxy(conf *config.Config, version string) error { //nolint: funlen, cyc Secret: conf.Secret, Concurrency: conf.GetConcurrency(mtglib.DefaultConcurrency), DomainFrontingPort: conf.GetDomainFrontingPort(mtglib.DefaultDomainFrontingPort), - DomainFrontingIP: conf.GetDomainFrontingIP(nil), + DomainFrontingHost: conf.GetDomainFrontingHost(), DomainFrontingProxyProtocol: conf.GetDomainFrontingProxyProtocol(false), PreferIP: conf.PreferIP.Get(mtglib.DefaultPreferIP), AutoUpdate: conf.AutoUpdate.Get(false), diff --git a/internal/cli/simple_run.go b/internal/cli/simple_run.go index 192cf3cb8..2caa4fb5c 100644 --- a/internal/cli/simple_run.go +++ b/internal/cli/simple_run.go @@ -17,8 +17,9 @@ type SimpleRun struct { Concurrency uint64 `kong:"name='concurrency',short='c',default='8192',help='Max number of concurrent connection to proxy.'"` //nolint: lll TCPBuffer string `kong:"name='tcp-buffer',short='b',default='4KB',help='Deprecated and ignored'"` //nolint: lll PreferIP string `kong:"name='prefer-ip',short='i',default='prefer-ipv6',help='IP preference. By default we prefer IPv6 with fallback to IPv4.'"` //nolint: lll - DomainFrontingPort uint64 `kong:"name='domain-fronting-port',short='p',default='443',help='A port to access for domain fronting.'"` //nolint: lll - DomainFrontingIP string `kong:"name='domain-fronting-ip',help='An IP address to use for domain fronting instead of resolving the hostname via DNS.'"` //nolint: lll + DomainFrontingPort uint64 `kong:"name='domain-fronting-port',short='p',default='443',help='A port to access for domain fronting.'"` //nolint: lll + DomainFrontingHost string `kong:"name='domain-fronting-host',help='Hostname or IP to dial for domain fronting instead of resolving the secret hostname.'"` //nolint: lll + DomainFrontingIP string `kong:"name='domain-fronting-ip',help='Deprecated: use --domain-fronting-host. Setting this flag logs a warning at startup and the value is ignored.'"` //nolint: lll DOHIP net.IP `kong:"name='doh-ip',short='n',default='1.1.1.1',help='IP address of DNS-over-HTTP to use.'"` //nolint: lll Timeout time.Duration `kong:"name='timeout',short='t',default='10s',help='Network timeout to use'"` //nolint: lll Socks5Proxies []string `kong:"name='socks5-proxy',short='s',help='Socks5 proxies to use for network access.'"` //nolint: lll @@ -48,6 +49,15 @@ func (s *SimpleRun) Run(cli *CLI, version string) error { //nolint: cyclop,funle return fmt.Errorf("incorrect domain-fronting-port: %w", err) } + if s.DomainFrontingHost != "" { + if err := conf.DomainFronting.Host.Set(s.DomainFrontingHost); err != nil { + return fmt.Errorf("incorrect domain-fronting-host: %w", err) + } + } + + // --domain-fronting-ip is deprecated; the value is parsed only so the + // runtime check in runProxy can detect it and emit the warn-and-ignore + // log message. The value never reaches the dial path. if s.DomainFrontingIP != "" { if err := conf.DomainFrontingIP.Set(s.DomainFrontingIP); err != nil { return fmt.Errorf("incorrect domain-fronting-ip: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index 70e233f17..f7a33e3c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "net" "net/url" "github.com/9seconds/mtg/v2/mtglib" @@ -38,6 +37,7 @@ type Config struct { PublicIPv4 TypeIP `json:"publicIpv4"` PublicIPv6 TypeIP `json:"publicIpv6"` DomainFronting struct { + Host TypeHost `json:"host"` IP TypeIP `json:"ip"` Port TypePort `json:"port"` ProxyProtocol TypeBool `json:"proxyProtocol"` @@ -117,14 +117,8 @@ func (c *Config) GetDomainFrontingPort(defaultValue uint) uint { return c.DomainFrontingPort.Get(defaultValue) } -func (c *Config) GetDomainFrontingIP(defaultValue net.IP) string { - if ip := c.DomainFronting.IP.Get(nil); ip != nil { - return ip.String() - } - if ip := c.DomainFrontingIP.Get(defaultValue); ip != nil { - return ip.String() - } - return "" +func (c *Config) GetDomainFrontingHost() string { + return c.DomainFronting.Host.Get("") } func (c *Config) GetDomainFrontingProxyProtocol(defaultValue bool) bool { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c291c29f8..25c98233e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -74,6 +74,47 @@ func (suite *ConfigTestSuite) TestString() { suite.NotEmpty(conf.String()) } +func (suite *ConfigTestSuite) TestDomainFrontingIPIgnoredWhenHostSet() { + conf, err := config.Parse(suite.ReadConfig("minimal.toml")) + suite.NoError(err) + + suite.NoError(conf.DomainFronting.Host.Set("fronting-backend")) + suite.NoError(conf.DomainFronting.IP.Set("10.0.0.10")) + suite.NoError(conf.Validate()) + suite.Equal("fronting-backend", conf.GetDomainFrontingHost()) +} + +func (suite *ConfigTestSuite) TestDomainFrontingHostFromTOML() { + conf, err := config.Parse(suite.ReadConfig("domain_fronting_host.toml")) + suite.NoError(err) + suite.NoError(conf.Validate()) + suite.Equal("fronting-backend", conf.GetDomainFrontingHost()) +} + +func (suite *ConfigTestSuite) TestDomainFrontingHostAcceptsLiteralIP() { + conf, err := config.Parse(suite.ReadConfig("domain_fronting_host_ip.toml")) + suite.NoError(err) + suite.NoError(conf.Validate()) + suite.Equal("10.0.0.1", conf.GetDomainFrontingHost()) +} + +func (suite *ConfigTestSuite) TestDomainFrontingIPIgnoredFromTOML() { + conf, err := config.Parse(suite.ReadConfig("domain_fronting_ip.toml")) + suite.NoError(err) + suite.NoError(conf.Validate()) + // Deprecated [domain-fronting].ip is parsed but never used to derive + // the dial target — the user must migrate to [domain-fronting].host. + suite.NotNil(conf.DomainFronting.IP.Get(nil)) + suite.Equal("", conf.GetDomainFrontingHost()) +} + +func (suite *ConfigTestSuite) TestDomainFrontingNotSet() { + conf, err := config.Parse(suite.ReadConfig("minimal.toml")) + suite.NoError(err) + suite.NoError(conf.Validate()) + suite.Equal("", conf.GetDomainFrontingHost()) +} + func TestConfig(t *testing.T) { t.Parallel() suite.Run(t, &ConfigTestSuite{}) diff --git a/internal/config/parse.go b/internal/config/parse.go index bdc76162d..40925b0bc 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -24,6 +24,7 @@ type tomlConfig struct { PublicIPv4 string `toml:"public-ipv4" json:"publicIpv4,omitempty"` PublicIPv6 string `toml:"public-ipv6" json:"publicIpv6,omitempty"` DomainFronting struct { + Host string `toml:"host" json:"host,omitempty"` IP string `toml:"ip" json:"ip,omitempty"` Port uint `toml:"port" json:"port,omitempty"` ProxyProtocol bool `toml:"proxy-protocol" json:"proxyProtocol,omitempty"` diff --git a/internal/config/testdata/domain_fronting_host.toml b/internal/config/testdata/domain_fronting_host.toml new file mode 100644 index 000000000..a9349259b --- /dev/null +++ b/internal/config/testdata/domain_fronting_host.toml @@ -0,0 +1,5 @@ +secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t" +bind-to = "0.0.0.0:3128" + +[domain-fronting] +host = "fronting-backend" diff --git a/internal/config/testdata/domain_fronting_host_ip.toml b/internal/config/testdata/domain_fronting_host_ip.toml new file mode 100644 index 000000000..ed0655936 --- /dev/null +++ b/internal/config/testdata/domain_fronting_host_ip.toml @@ -0,0 +1,5 @@ +secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t" +bind-to = "0.0.0.0:3128" + +[domain-fronting] +host = "10.0.0.1" diff --git a/internal/config/testdata/domain_fronting_ip.toml b/internal/config/testdata/domain_fronting_ip.toml new file mode 100644 index 000000000..fa751ebe9 --- /dev/null +++ b/internal/config/testdata/domain_fronting_ip.toml @@ -0,0 +1,5 @@ +secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t" +bind-to = "0.0.0.0:3128" + +[domain-fronting] +ip = "10.0.0.10" diff --git a/internal/config/type_host.go b/internal/config/type_host.go new file mode 100644 index 000000000..f13e0839b --- /dev/null +++ b/internal/config/type_host.go @@ -0,0 +1,61 @@ +package config + +import ( + "fmt" + "net" + "strings" +) + +// TypeHost is a non-empty string that is either a literal IP address +// (IPv4 or IPv6) or a hostname suitable for DNS resolution. It does not +// include a port — the port belongs in a separate field. +type TypeHost struct { + Value string +} + +func (t *TypeHost) Set(value string) error { + if value == "" { + return fmt.Errorf("host cannot be empty") + } + + if net.ParseIP(value) != nil { + t.Value = value + + return nil + } + + if strings.ContainsAny(value, " \t\n/?#") { + return fmt.Errorf("incorrect host %q", value) + } + + // At this point value is not a parsed IP (IPv6 literals returned + // above), so any remaining colon indicates a host:port form, which + // belongs in a separate field. + if strings.Contains(value, ":") { + return fmt.Errorf("host must not contain a port: %q", value) + } + + t.Value = value + + return nil +} + +func (t TypeHost) Get(defaultValue string) string { + if t.Value == "" { + return defaultValue + } + + return t.Value +} + +func (t *TypeHost) UnmarshalText(data []byte) error { + return t.Set(string(data)) +} + +func (t TypeHost) MarshalText() ([]byte, error) { + return []byte(t.Value), nil +} + +func (t TypeHost) String() string { + return t.Value +} diff --git a/internal/config/type_host_test.go b/internal/config/type_host_test.go new file mode 100644 index 000000000..a67a55d91 --- /dev/null +++ b/internal/config/type_host_test.go @@ -0,0 +1,77 @@ +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 typeHostTestStruct struct { + Value config.TypeHost `json:"value"` +} + +type TypeHostTestSuite struct { + suite.Suite +} + +func (suite *TypeHostTestSuite) TestUnmarshalFail() { + testData := []string{ + "", + "web:8443", + "http://example.com", + "example.com/path", + "two words", + } + + 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, &typeHostTestStruct{})) + }) + } +} + +func (suite *TypeHostTestSuite) TestUnmarshalOk() { + testData := []string{ + "example.com", + "web", + "sub.example.com", + "127.0.0.1", + "2001:db8::1", + } + + for _, v := range testData { + value := v + + data, err := json.Marshal(map[string]string{ + "value": value, + }) + suite.NoError(err) + + suite.T().Run(value, func(t *testing.T) { + testStruct := &typeHostTestStruct{} + assert.NoError(t, json.Unmarshal(data, testStruct)) + assert.Equal(t, value, testStruct.Value.Get("")) + }) + } +} + +func (suite *TypeHostTestSuite) TestGet() { + value := config.TypeHost{} + suite.Equal("default", value.Get("default")) + + suite.NoError(value.Set("example.com")) + suite.Equal("example.com", value.Get("default")) +} + +func TestTypeHost(t *testing.T) { + t.Parallel() + suite.Run(t, &TypeHostTestSuite{}) +} diff --git a/mtglib/proxy.go b/mtglib/proxy.go index a89e18cbf..9431c31d4 100644 --- a/mtglib/proxy.go +++ b/mtglib/proxy.go @@ -30,7 +30,7 @@ type Proxy struct { idleTimeout time.Duration handshakeTimeout time.Duration domainFrontingPort int - domainFrontingIP string + domainFrontingHost string domainFrontingProxyProtocol bool workerPool *ants.PoolWithFunc telegram *dc.Telegram @@ -48,11 +48,12 @@ type Proxy struct { } // DomainFrontingAddress returns a host:port pair for a fronting domain. -// If DomainFrontingIP is set, it is used instead of resolving the hostname. +// If a fronting host (literal IP or hostname) is configured, it is used +// instead of resolving the secret's hostname. func (p *Proxy) DomainFrontingAddress() string { host := p.secret.Host - if p.domainFrontingIP != "" { - host = p.domainFrontingIP + if p.domainFrontingHost != "" { + host = p.domainFrontingHost } return net.JoinHostPort(host, strconv.Itoa(p.domainFrontingPort)) @@ -343,6 +344,10 @@ func NewProxy(opts ProxyOpts) (*Proxy, error) { logger := opts.getLogger("proxy") updatersLogger := logger.Named("telegram-updaters") + if opts.DomainFrontingIP != "" { + logger.Warning("mtglib.ProxyOpts.DomainFrontingIP is deprecated and ignored; use DomainFrontingHost instead") + } + proxy := &Proxy{ ctx: ctx, ctxCancel: cancel, @@ -354,7 +359,7 @@ func NewProxy(opts ProxyOpts) (*Proxy, error) { eventStream: opts.EventStream, logger: logger, domainFrontingPort: opts.getDomainFrontingPort(), - domainFrontingIP: opts.DomainFrontingIP, + domainFrontingHost: opts.DomainFrontingHost, tolerateTimeSkewness: opts.getTolerateTimeSkewness(), idleTimeout: opts.getIdleTimeout(), handshakeTimeout: opts.getHandshakeTimeout(), diff --git a/mtglib/proxy_opts.go b/mtglib/proxy_opts.go index 8dc69a5bd..41ffa84e0 100644 --- a/mtglib/proxy_opts.go +++ b/mtglib/proxy_opts.go @@ -105,13 +105,24 @@ type ProxyOpts struct { // This is an optional setting. DomainFrontingPort uint - // DomainFrontingIP is an IP address to use when connecting to the fronting - // domain instead of resolving the hostname from the secret via DNS. + // DomainFrontingHost is the address to use when connecting to the + // fronting domain instead of resolving the hostname from the secret via + // DNS. It can be a literal IP or a hostname; hostnames are resolved at + // dial time via the native dialer (which honours dual-stack and Happy + // Eyeballs). // - // This is useful when DNS resolution of the fronting host is blocked. - // The hostname from the secret is still used for SNI in the TLS handshake. + // This is useful when DNS resolution of the secret's hostname is blocked + // or loops back to this server. The hostname from the secret is still + // used for SNI in the TLS handshake. // // This is an optional setting. + DomainFrontingHost string + + // DomainFrontingIP previously held the dial target for the fronting + // domain. The setting is no longer honoured: setting it logs a warning + // at proxy startup and the value is dropped. + // + // Deprecated: use DomainFrontingHost. Setting this field has no effect. DomainFrontingIP string // DomainFrontingProxyProtocol is used if communication between upstream