From bada0e48b405810951f61c47edc661d28688991d Mon Sep 17 00:00:00 2001 From: Loyalsoldier <10487845+Loyalsoldier@users.noreply.github.com> Date: Sat, 10 Apr 2021 21:56:08 +0800 Subject: [PATCH] Feat: DNS hosts support multiple addresses (#884) --- app/dns/dns.go | 2 +- app/dns/hosts.go | 5 - app/dns/hosts_test.go | 1 + infra/conf/dns.go | 282 +++++++++++++++++++++++++---------------- infra/conf/dns_test.go | 14 +- 5 files changed, 182 insertions(+), 122 deletions(-) diff --git a/app/dns/dns.go b/app/dns/dns.go index a3ef660df..4c32ed541 100644 --- a/app/dns/dns.go +++ b/app/dns/dns.go @@ -214,7 +214,7 @@ func (s *DNS) lookupIPInternal(domain string, option dns.IPOption) ([]net.IP, er newError("domain replaced: ", domain, " -> ", addrs[0].Domain()).WriteToLog() domain = addrs[0].Domain() default: // Successfully found ip records in static host - newError("returning ", len(addrs), " IPs for domain ", domain).WriteToLog() + newError("returning ", len(addrs), " IPs for domain ", domain, " -> ", addrs).WriteToLog() return toNetIP(addrs) } diff --git a/app/dns/hosts.go b/app/dns/hosts.go index 28a40f440..94746e0ae 100644 --- a/app/dns/hosts.go +++ b/app/dns/hosts.go @@ -65,11 +65,6 @@ func NewStaticHosts(hosts []*Config_HostMapping, legacy map[string]*net.IPOrDoma return nil, newError("neither IP address nor proxied domain specified for domain: ", mapping.Domain).AtWarning() } - // Special handling for localhost IPv6. This is a dirty workaround as JSON config supports only single IP mapping. - if len(ips) == 1 && ips[0] == net.LocalHostIP { - ips = append(ips, net.LocalHostIPv6) - } - sh.ips[id] = ips } diff --git a/app/dns/hosts_test.go b/app/dns/hosts_test.go index 293fda440..6bf2caf22 100644 --- a/app/dns/hosts_test.go +++ b/app/dns/hosts_test.go @@ -32,6 +32,7 @@ func TestStaticHosts(t *testing.T) { Domain: "baidu.com", Ip: [][]byte{ {127, 0, 0, 1}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, }, }, } diff --git a/infra/conf/dns.go b/infra/conf/dns.go index 17726d251..827aedc76 100644 --- a/infra/conf/dns.go +++ b/infra/conf/dns.go @@ -125,7 +125,7 @@ var typeMap = map[router.Domain_Type]dns.DomainMatchingType{ // DNSConfig is a JSON serializable object for dns.Config. type DNSConfig struct { Servers []*NameServerConfig `json:"servers"` - Hosts map[string]*Address `json:"hosts"` + Hosts *HostsWrapper `json:"hosts"` ClientIP *Address `json:"clientIp"` Tag string `json:"tag"` QueryStrategy string `json:"queryStrategy"` @@ -133,15 +133,174 @@ type DNSConfig struct { DisableFallback bool `json:"disableFallback"` } -func getHostMapping(addr *Address) *dns.Config_HostMapping { - if addr.Family().IsIP() { +type HostAddress struct { + addr *Address + addrs []*Address +} + +// UnmarshalJSON implements encoding/json.Unmarshaler.UnmarshalJSON +func (h *HostAddress) UnmarshalJSON(data []byte) error { + addr := new(Address) + var addrs []*Address + switch { + case json.Unmarshal(data, &addr) == nil: + h.addr = addr + case json.Unmarshal(data, &addrs) == nil: + h.addrs = addrs + default: + return newError("invalid address") + } + return nil +} + +type HostsWrapper struct { + Hosts map[string]*HostAddress +} + +func getHostMapping(ha *HostAddress) *dns.Config_HostMapping { + if ha.addr != nil { + if ha.addr.Family().IsDomain() { + return &dns.Config_HostMapping{ + ProxiedDomain: ha.addr.Domain(), + } + } return &dns.Config_HostMapping{ - Ip: [][]byte{[]byte(addr.IP())}, + Ip: [][]byte{ha.addr.IP()}, } } - return &dns.Config_HostMapping{ - ProxiedDomain: addr.Domain(), + + ips := make([][]byte, 0, len(ha.addrs)) + for _, addr := range ha.addrs { + if addr.Family().IsDomain() { + return &dns.Config_HostMapping{ + ProxiedDomain: addr.Domain(), + } + } + ips = append(ips, []byte(addr.IP())) } + return &dns.Config_HostMapping{ + Ip: ips, + } +} + +// UnmarshalJSON implements encoding/json.Unmarshaler.UnmarshalJSON +func (m *HostsWrapper) UnmarshalJSON(data []byte) error { + hosts := make(map[string]*HostAddress) + err := json.Unmarshal(data, &hosts) + if err == nil { + m.Hosts = hosts + return nil + } + return newError("invalid DNS hosts").Base(err) +} + +// Build implements Buildable +func (m *HostsWrapper) Build() ([]*dns.Config_HostMapping, error) { + mappings := make([]*dns.Config_HostMapping, 0, 20) + + domains := make([]string, 0, len(m.Hosts)) + for domain := range m.Hosts { + domains = append(domains, domain) + } + sort.Strings(domains) + + for _, domain := range domains { + switch { + case strings.HasPrefix(domain, "domain:"): + domainName := domain[7:] + if len(domainName) == 0 { + return nil, newError("empty domain type of rule: ", domain) + } + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = dns.DomainMatchingType_Subdomain + mapping.Domain = domainName + mappings = append(mappings, mapping) + + case strings.HasPrefix(domain, "geosite:"): + listName := domain[8:] + if len(listName) == 0 { + return nil, newError("empty geosite rule: ", domain) + } + geositeList, err := loadGeosite(listName) + if err != nil { + return nil, newError("failed to load geosite: ", listName).Base(err) + } + for _, d := range geositeList { + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = typeMap[d.Type] + mapping.Domain = d.Value + mappings = append(mappings, mapping) + } + + case strings.HasPrefix(domain, "regexp:"): + regexpVal := domain[7:] + if len(regexpVal) == 0 { + return nil, newError("empty regexp type of rule: ", domain) + } + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = dns.DomainMatchingType_Regex + mapping.Domain = regexpVal + mappings = append(mappings, mapping) + + case strings.HasPrefix(domain, "keyword:"): + keywordVal := domain[8:] + if len(keywordVal) == 0 { + return nil, newError("empty keyword type of rule: ", domain) + } + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = dns.DomainMatchingType_Keyword + mapping.Domain = keywordVal + mappings = append(mappings, mapping) + + case strings.HasPrefix(domain, "full:"): + fullVal := domain[5:] + if len(fullVal) == 0 { + return nil, newError("empty full domain type of rule: ", domain) + } + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = dns.DomainMatchingType_Full + mapping.Domain = fullVal + mappings = append(mappings, mapping) + + case strings.HasPrefix(domain, "dotless:"): + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = dns.DomainMatchingType_Regex + switch substr := domain[8:]; { + case substr == "": + mapping.Domain = "^[^.]*$" + case !strings.Contains(substr, "."): + mapping.Domain = "^[^.]*" + substr + "[^.]*$" + default: + return nil, newError("substr in dotless rule should not contain a dot: ", substr) + } + mappings = append(mappings, mapping) + + case strings.HasPrefix(domain, "ext:"): + kv := strings.Split(domain[4:], ":") + if len(kv) != 2 { + return nil, newError("invalid external resource: ", domain) + } + filename := kv[0] + list := kv[1] + geositeList, err := loadGeositeWithAttr(filename, list) + if err != nil { + return nil, newError("failed to load domain list: ", list, " from ", filename).Base(err) + } + for _, d := range geositeList { + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = typeMap[d.Type] + mapping.Domain = d.Value + mappings = append(mappings, mapping) + } + + default: + mapping := getHostMapping(m.Hosts[domain]) + mapping.Type = dns.DomainMatchingType_Full + mapping.Domain = domain + mappings = append(mappings, mapping) + } + } + return mappings, nil } // Build implements Buildable @@ -177,113 +336,12 @@ func (c *DNSConfig) Build() (*dns.Config, error) { config.NameServer = append(config.NameServer, ns) } - if c.Hosts != nil && len(c.Hosts) > 0 { - domains := make([]string, 0, len(c.Hosts)) - for domain := range c.Hosts { - domains = append(domains, domain) - } - sort.Strings(domains) - - for _, domain := range domains { - addr := c.Hosts[domain] - var mappings []*dns.Config_HostMapping - switch { - case strings.HasPrefix(domain, "domain:"): - domainName := domain[7:] - if len(domainName) == 0 { - return nil, newError("empty domain type of rule: ", domain) - } - mapping := getHostMapping(addr) - mapping.Type = dns.DomainMatchingType_Subdomain - mapping.Domain = domainName - mappings = append(mappings, mapping) - - case strings.HasPrefix(domain, "geosite:"): - listName := domain[8:] - if len(listName) == 0 { - return nil, newError("empty geosite rule: ", domain) - } - domains, err := loadGeosite(listName) - if err != nil { - return nil, newError("failed to load geosite: ", listName).Base(err) - } - for _, d := range domains { - mapping := getHostMapping(addr) - mapping.Type = typeMap[d.Type] - mapping.Domain = d.Value - mappings = append(mappings, mapping) - } - - case strings.HasPrefix(domain, "regexp:"): - regexpVal := domain[7:] - if len(regexpVal) == 0 { - return nil, newError("empty regexp type of rule: ", domain) - } - mapping := getHostMapping(addr) - mapping.Type = dns.DomainMatchingType_Regex - mapping.Domain = regexpVal - mappings = append(mappings, mapping) - - case strings.HasPrefix(domain, "keyword:"): - keywordVal := domain[8:] - if len(keywordVal) == 0 { - return nil, newError("empty keyword type of rule: ", domain) - } - mapping := getHostMapping(addr) - mapping.Type = dns.DomainMatchingType_Keyword - mapping.Domain = keywordVal - mappings = append(mappings, mapping) - - case strings.HasPrefix(domain, "full:"): - fullVal := domain[5:] - if len(fullVal) == 0 { - return nil, newError("empty full domain type of rule: ", domain) - } - mapping := getHostMapping(addr) - mapping.Type = dns.DomainMatchingType_Full - mapping.Domain = fullVal - mappings = append(mappings, mapping) - - case strings.HasPrefix(domain, "dotless:"): - mapping := getHostMapping(addr) - mapping.Type = dns.DomainMatchingType_Regex - switch substr := domain[8:]; { - case substr == "": - mapping.Domain = "^[^.]*$" - case !strings.Contains(substr, "."): - mapping.Domain = "^[^.]*" + substr + "[^.]*$" - default: - return nil, newError("substr in dotless rule should not contain a dot: ", substr) - } - mappings = append(mappings, mapping) - - case strings.HasPrefix(domain, "ext:"): - kv := strings.Split(domain[4:], ":") - if len(kv) != 2 { - return nil, newError("invalid external resource: ", domain) - } - filename := kv[0] - list := kv[1] - domains, err := loadGeositeWithAttr(filename, list) - if err != nil { - return nil, newError("failed to load domain list: ", list, " from ", filename).Base(err) - } - for _, d := range domains { - mapping := getHostMapping(addr) - mapping.Type = typeMap[d.Type] - mapping.Domain = d.Value - mappings = append(mappings, mapping) - } - - default: - mapping := getHostMapping(addr) - mapping.Type = dns.DomainMatchingType_Full - mapping.Domain = domain - mappings = append(mappings, mapping) - } - - config.StaticHosts = append(config.StaticHosts, mappings...) + if c.Hosts != nil { + staticHosts, err := c.Hosts.Build() + if err != nil { + return nil, newError("failed to build hosts").Base(err) } + config.StaticHosts = append(config.StaticHosts, staticHosts...) } return config, nil diff --git a/infra/conf/dns_test.go b/infra/conf/dns_test.go index a99f0c93f..e7e7d6a20 100644 --- a/infra/conf/dns_test.go +++ b/infra/conf/dns_test.go @@ -69,9 +69,10 @@ func TestDNSConfigParsing(t *testing.T) { }], "hosts": { "v2fly.org": "127.0.0.1", + "www.v2fly.org": ["1.2.3.4", "5.6.7.8"], "domain:example.com": "google.com", - "geosite:test": "10.0.0.1", - "keyword:google": "8.8.8.8", + "geosite:test": ["127.0.0.1", "127.0.0.2"], + "keyword:google": ["8.8.8.8", "8.8.4.4"], "regexp:.*\\.com": "8.8.4.4" }, "clientIp": "10.0.0.1", @@ -117,12 +118,12 @@ func TestDNSConfigParsing(t *testing.T) { { Type: dns.DomainMatchingType_Full, Domain: "test.example.com", - Ip: [][]byte{{10, 0, 0, 1}}, + Ip: [][]byte{{127, 0, 0, 1}, {127, 0, 0, 2}}, }, { Type: dns.DomainMatchingType_Keyword, Domain: "google", - Ip: [][]byte{{8, 8, 8, 8}}, + Ip: [][]byte{{8, 8, 8, 8}, {8, 8, 4, 4}}, }, { Type: dns.DomainMatchingType_Regex, @@ -134,6 +135,11 @@ func TestDNSConfigParsing(t *testing.T) { Domain: "v2fly.org", Ip: [][]byte{{127, 0, 0, 1}}, }, + { + Type: dns.DomainMatchingType_Full, + Domain: "www.v2fly.org", + Ip: [][]byte{{1, 2, 3, 4}, {5, 6, 7, 8}}, + }, }, ClientIp: []byte{10, 0, 0, 1}, QueryStrategy: dns.QueryStrategy_USE_IP4,