ソースを参照

Accept hostname for [domain-fronting] target

The existing `[domain-fronting].ip` only accepts a literal IP. That
forces SNI-router setups to pin a static container address (and a
static docker subnet) so mtg can dial the fronting backend directly
instead of resolving the secret's hostname via DNS, which would loop
back into mtg through the SNI router.

Add a sibling `[domain-fronting].host` that accepts either a hostname
or an IP. Hostnames are resolved at dial time by the native dialer
(Happy Eyeballs / dual-stack), so a docker-DNS or any A+AAAA record
naturally picks the right backend address family per client. Setting
both `host` and `ip` is rejected at validation.

The mtglib API stays backward compatible: ProxyOpts.DomainFrontingIP
is still a plain string and the dial path already calls JoinHostPort +
DialContext, both of which accept hostnames. Only the doc comment was
clarified.
pull/480/head
Alexey Dolotov 1週間前
コミット
46ffe4e3cc

+ 12
- 4
example.config.toml ファイルの表示

@@ -112,11 +112,19 @@ allow-fallback-on-unknown-dc = false
112 112
 # required.
113 113
 [domain-fronting]
114 114
 # By default, mtg resolves the fronting hostname (from the secret) via DNS
115
-# to establish a TCP connection. If DNS resolution of that hostname is blocked,
116
-# you can specify an IP address to connect to directly. The hostname is still
117
-# used for SNI in the TLS handshake.
115
+# to establish a TCP connection. If that resolution is blocked, or loops
116
+# back to this server (e.g. mtg sits behind an SNI router whose DNS points
117
+# at itself), override the destination here.
118 118
 #
119
-# default value is not set (DNS resolution is used).
119
+# Use `host` for a hostname (resolved at dial time, so a dual-stack DNS
120
+# record can reach the right backend address family for IPv4 or IPv6
121
+# clients) or for a literal IP. Use `ip` if you specifically need a
122
+# literal IP and want strict IP validation. Setting both is an error.
123
+#
124
+# The hostname from the secret is still used for SNI in the TLS handshake.
125
+#
126
+# default value is not set (the secret's hostname is used).
127
+# host = "fronting-backend"
120 128
 # ip = "10.10.10.11"
121 129
 
122 130
 # FakeTLS uses domain fronting protection. So it needs to know a port to

+ 8
- 0
internal/config/config.go ファイルの表示

@@ -39,6 +39,7 @@ type Config struct {
39 39
 	PublicIPv6                  TypeIP          `json:"publicIpv6"`
40 40
 	DomainFronting              struct {
41 41
 		IP            TypeIP   `json:"ip"`
42
+		Host          TypeHost `json:"host"`
42 43
 		Port          TypePort `json:"port"`
43 44
 		ProxyProtocol TypeBool `json:"proxyProtocol"`
44 45
 	} `json:"domainFronting"`
@@ -118,6 +119,9 @@ func (c *Config) GetDomainFrontingPort(defaultValue uint) uint {
118 119
 }
119 120
 
120 121
 func (c *Config) GetDomainFrontingIP(defaultValue net.IP) string {
122
+	if host := c.DomainFronting.Host.Get(""); host != "" {
123
+		return host
124
+	}
121 125
 	if ip := c.DomainFronting.IP.Get(nil); ip != nil {
122 126
 		return ip.String()
123 127
 	}
@@ -140,6 +144,10 @@ func (c *Config) Validate() error {
140 144
 		return fmt.Errorf("incorrect bind-to parameter %s", c.BindTo.String())
141 145
 	}
142 146
 
147
+	if c.DomainFronting.Host.Get("") != "" && c.DomainFronting.IP.Get(nil) != nil {
148
+		return fmt.Errorf("[domain-fronting] host and ip are mutually exclusive; pick one")
149
+	}
150
+
143 151
 	return nil
144 152
 }
145 153
 

+ 11
- 0
internal/config/config_test.go ファイルの表示

@@ -74,6 +74,17 @@ func (suite *ConfigTestSuite) TestString() {
74 74
 	suite.NotEmpty(conf.String())
75 75
 }
76 76
 
77
+func (suite *ConfigTestSuite) TestDomainFrontingHostOrIP() {
78
+	conf, err := config.Parse(suite.ReadConfig("minimal.toml"))
79
+	suite.NoError(err)
80
+
81
+	suite.NoError(conf.DomainFronting.Host.Set("fronting-backend"))
82
+	suite.NoError(conf.DomainFronting.IP.Set("10.0.0.10"))
83
+	suite.Error(conf.Validate())
84
+
85
+	suite.Equal("fronting-backend", conf.GetDomainFrontingIP(nil))
86
+}
87
+
77 88
 func TestConfig(t *testing.T) {
78 89
 	t.Parallel()
79 90
 	suite.Run(t, &ConfigTestSuite{})

+ 1
- 0
internal/config/parse.go ファイルの表示

@@ -25,6 +25,7 @@ type tomlConfig struct {
25 25
 	PublicIPv6                  string `toml:"public-ipv6" json:"publicIpv6,omitempty"`
26 26
 	DomainFronting              struct {
27 27
 		IP            string `toml:"ip" json:"ip,omitempty"`
28
+		Host          string `toml:"host" json:"host,omitempty"`
28 29
 		Port          uint   `toml:"port" json:"port,omitempty"`
29 30
 		ProxyProtocol bool   `toml:"proxy-protocol" json:"proxyProtocol,omitempty"`
30 31
 	} `toml:"domain-fronting" json:"domainFronting,omitempty"`

+ 58
- 0
internal/config/type_host.go ファイルの表示

@@ -0,0 +1,58 @@
1
+package config
2
+
3
+import (
4
+	"fmt"
5
+	"net"
6
+	"strings"
7
+)
8
+
9
+// TypeHost is a non-empty string that is either a literal IP address
10
+// (IPv4 or IPv6) or a hostname suitable for DNS resolution. It does not
11
+// include a port — the port belongs in a separate field.
12
+type TypeHost struct {
13
+	Value string
14
+}
15
+
16
+func (t *TypeHost) Set(value string) error {
17
+	if value == "" {
18
+		return fmt.Errorf("host cannot be empty")
19
+	}
20
+
21
+	if net.ParseIP(value) != nil {
22
+		t.Value = value
23
+
24
+		return nil
25
+	}
26
+
27
+	if strings.ContainsAny(value, " \t\n/?#") {
28
+		return fmt.Errorf("incorrect host %q", value)
29
+	}
30
+
31
+	if strings.Contains(value, ":") {
32
+		return fmt.Errorf("host must not contain a port: %q", value)
33
+	}
34
+
35
+	t.Value = value
36
+
37
+	return nil
38
+}
39
+
40
+func (t TypeHost) Get(defaultValue string) string {
41
+	if t.Value == "" {
42
+		return defaultValue
43
+	}
44
+
45
+	return t.Value
46
+}
47
+
48
+func (t *TypeHost) UnmarshalText(data []byte) error {
49
+	return t.Set(string(data))
50
+}
51
+
52
+func (t TypeHost) MarshalText() ([]byte, error) {
53
+	return []byte(t.Value), nil
54
+}
55
+
56
+func (t TypeHost) String() string {
57
+	return t.Value
58
+}

+ 77
- 0
internal/config/type_host_test.go ファイルの表示

@@ -0,0 +1,77 @@
1
+package config_test
2
+
3
+import (
4
+	"encoding/json"
5
+	"testing"
6
+
7
+	"github.com/9seconds/mtg/v2/internal/config"
8
+	"github.com/stretchr/testify/assert"
9
+	"github.com/stretchr/testify/suite"
10
+)
11
+
12
+type typeHostTestStruct struct {
13
+	Value config.TypeHost `json:"value"`
14
+}
15
+
16
+type TypeHostTestSuite struct {
17
+	suite.Suite
18
+}
19
+
20
+func (suite *TypeHostTestSuite) TestUnmarshalFail() {
21
+	testData := []string{
22
+		"",
23
+		"web:8443",
24
+		"http://example.com",
25
+		"example.com/path",
26
+		"two words",
27
+	}
28
+
29
+	for _, v := range testData {
30
+		data, err := json.Marshal(map[string]string{
31
+			"value": v,
32
+		})
33
+		suite.NoError(err)
34
+
35
+		suite.T().Run(v, func(t *testing.T) {
36
+			assert.Error(t, json.Unmarshal(data, &typeHostTestStruct{}))
37
+		})
38
+	}
39
+}
40
+
41
+func (suite *TypeHostTestSuite) TestUnmarshalOk() {
42
+	testData := []string{
43
+		"example.com",
44
+		"web",
45
+		"sub.example.com",
46
+		"127.0.0.1",
47
+		"2001:db8::1",
48
+	}
49
+
50
+	for _, v := range testData {
51
+		value := v
52
+
53
+		data, err := json.Marshal(map[string]string{
54
+			"value": value,
55
+		})
56
+		suite.NoError(err)
57
+
58
+		suite.T().Run(value, func(t *testing.T) {
59
+			testStruct := &typeHostTestStruct{}
60
+			assert.NoError(t, json.Unmarshal(data, testStruct))
61
+			assert.Equal(t, value, testStruct.Value.Get(""))
62
+		})
63
+	}
64
+}
65
+
66
+func (suite *TypeHostTestSuite) TestGet() {
67
+	value := config.TypeHost{}
68
+	suite.Equal("default", value.Get("default"))
69
+
70
+	suite.NoError(value.Set("example.com"))
71
+	suite.Equal("example.com", value.Get("default"))
72
+}
73
+
74
+func TestTypeHost(t *testing.T) {
75
+	t.Parallel()
76
+	suite.Run(t, &TypeHostTestSuite{})
77
+}

+ 8
- 5
mtglib/proxy_opts.go ファイルの表示

@@ -105,11 +105,14 @@ type ProxyOpts struct {
105 105
 	// This is an optional setting.
106 106
 	DomainFrontingPort uint
107 107
 
108
-	// DomainFrontingIP is an IP address to use when connecting to the fronting
109
-	// domain instead of resolving the hostname from the secret via DNS.
110
-	//
111
-	// This is useful when DNS resolution of the fronting host is blocked.
112
-	// The hostname from the secret is still used for SNI in the TLS handshake.
108
+	// DomainFrontingIP is the address to use when connecting to the fronting
109
+	// domain instead of resolving the hostname from the secret via DNS. It
110
+	// can be a literal IP or a hostname; hostnames are resolved at dial time
111
+	// via the native dialer (which honours dual-stack and Happy Eyeballs).
112
+	//
113
+	// This is useful when DNS resolution of the secret's hostname is blocked
114
+	// or loops back to this server. The hostname from the secret is still
115
+	// used for SNI in the TLS handshake.
113 116
 	//
114 117
 	// This is an optional setting.
115 118
 	DomainFrontingIP string

読み込み中…
キャンセル
保存