Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions example.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
}
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion internal/cli/run_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
14 changes: 12 additions & 2 deletions internal/cli/simple_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 3 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/url"

"github.com/9seconds/mtg/v2/mtglib"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
1 change: 1 addition & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
5 changes: 5 additions & 0 deletions internal/config/testdata/domain_fronting_host.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t"
bind-to = "0.0.0.0:3128"

[domain-fronting]
host = "fronting-backend"
5 changes: 5 additions & 0 deletions internal/config/testdata/domain_fronting_host_ip.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t"
bind-to = "0.0.0.0:3128"

[domain-fronting]
host = "10.0.0.1"
5 changes: 5 additions & 0 deletions internal/config/testdata/domain_fronting_ip.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t"
bind-to = "0.0.0.0:3128"

[domain-fronting]
ip = "10.0.0.10"
61 changes: 61 additions & 0 deletions internal/config/type_host.go
Original file line number Diff line number Diff line change
@@ -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/?#") {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if there is a possibility to validate that this domain is resolvable. We can set any double dutch here. IP is fine, but I do believe that we can do something about resolving hostname.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd push back on doing DNS at parse time. Three reasons:

  1. Codebase precedent. The closest existing field is mtglib.Secret.Host (the secret's SNI hostname), and Secret.Set() does no DNS validation — only non-empty. Same "any double dutch" risk, deliberate choice.

  2. The reachability check already lives in doctor. checkFrontingDomain() in internal/cli/doctor.go resolves and dials the fronting target end-to-end; with this PR it picks up host via GetDomainFrontingHost(). A bogus hostname surfaces the dialer's DNS error there. That's the right layer for semantic checks — explicit, opt-in, with proper diagnostics.

  3. Resolving at parse time defeats the point of accepting a hostname. The motivating case (mtg behind an SNI router on a docker network) specifically needs dial-time resolution: the alias may resolve in-container but not on the host, and the address family can flip between v4/v6 per client (Happy Eyeballs). If we resolve at parse, either we cache the IP and lose all that, or we discard and resolve again at dial — in which case the parse-time resolve is just a flaky boot dependency.

Operational: a transient DNS hiccup at startup would prevent the proxy from starting, and a one-shot resolve doesn't catch the host going stale later — so it adds fragility without much real safety.

If the concern is that doctor's message for an unresolvable host is too generic (it surfaces whatever DialContext returns), I can add an explicit LookupIPAddr step in checkFrontingDomain so the error reads "hostname X cannot be resolved" rather than being nested inside a dial error. Want me to wire that in?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks. I see your point. But I must reply on that argument:

The motivating case (mtg behind an SNI router on a docker network) specifically needs dial-time resolution: the alias may resolve in-container but not on the host, and the address family can flip between v4/v6 per client (Happy Eyeballs)

We should not think about different modes, host and container one. There should be only one environment we have to think about: one that runs mtg. If it happens to be in a container, let it be it. If it happens to be a generic host one, let it be a host one. If something is resolved on the host, but not in a container, then this is not a concern of mtg.

But this is not a performative concern, just my opinion in this regard. Such rigid behavior helps making a resilient software

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
}
77 changes: 77 additions & 0 deletions internal/config/type_host_test.go
Original file line number Diff line number Diff line change
@@ -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{})
}
Loading
Loading