Repository: v2fly/v2ray-core
Pull request #3006 (2024-05-07 16:32:39)

Support "services" root config in cfgv4
This allows tun to be configured in v4cfg:
```
  "services": {
    "tun": {
      "name": "tun0",
      "mtu": 1500,
      "tag": "tun",
      "ips": [{ "ip": [198, 18, 0, 0], "prefix": 15 }],
      "routes": [
         { "ip": [0, 0, 0, 0], "prefix": 0 }
         // , { "ip": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "prefix": 0 }
      ],
      "enablePromiscuousMode": true,
      "enableSpoofing": true
    }
  }
```
Fixes https://github.com/v2fly/v2ray-core/pull/2541#issuecomment-2076989572

Losalado: Great, thanks!

Patch:
diff --git a/app/dns/nameserver_doh.go b/app/dns/nameserver_doh.go
index 6e1430e6..b1889c16 100644
--- a/app/dns/nameserver_doh.go
+++ b/app/dns/nameserver_doh.go
@@ -11,7 +11,6 @@ import (
 	"net/http"
 	"net/url"
 	"sync"
-	"sync/atomic"
 	"time"
 
 	"golang.org/x/net/dns/dnsmessage"
@@ -35,7 +34,6 @@ type DoHNameServer struct {
 	ips        map[string]record
 	pub        *pubsub.Service
 	cleanup    *task.Periodic
-	reqID      uint32
 	httpClient *http.Client
 	dohURL     string
 	name       string
@@ -204,7 +202,7 @@ func (s *DoHNameServer) updateIP(req *dnsRequest, ipRec *IPRecord) {
 }
 
 func (s *DoHNameServer) newReqID() uint16 {
-	return uint16(atomic.AddUint32(&s.reqID, 1))
+	return 0
 }
 
 func (s *DoHNameServer) sendQuery(ctx context.Context, domain string, clientIP net.IP, option dns_feature.IPOption) {
diff --git a/app/dns/nameserver_quic.go b/app/dns/nameserver_quic.go
index 28e69225..7275cb8a 100644
--- a/app/dns/nameserver_quic.go
+++ b/app/dns/nameserver_quic.go
@@ -6,7 +6,6 @@ import (
 	"encoding/binary"
 	"net/url"
 	"sync"
-	"sync/atomic"
 	"time"
 
 	"github.com/quic-go/quic-go"
@@ -35,7 +34,6 @@ type QUICNameServer struct {
 	ips         map[string]record
 	pub         *pubsub.Service
 	cleanup     *task.Periodic
-	reqID       uint32
 	name        string
 	destination net.Destination
 	connection  quic.Connection
@@ -149,7 +147,7 @@ func (s *QUICNameServer) updateIP(req *dnsRequest, ipRec *IPRecord) {
 }
 
 func (s *QUICNameServer) newReqID() uint16 {
-	return uint16(atomic.AddUint32(&s.reqID, 1))
+	return 0
 }
 
 func (s *QUICNameServer) sendQuery(ctx context.Context, domain string, clientIP net.IP, option dns_feature.IPOption) {
diff --git a/infra/conf/merge/rules.go b/infra/conf/merge/rules.go
index 5ba3f909..51ccf2e6 100644
--- a/infra/conf/merge/rules.go
+++ b/infra/conf/merge/rules.go
@@ -4,6 +4,8 @@
 
 package merge
 
+import "fmt"
+
 const (
 	priorityKey string = "_priority"
 	tagKey      string = "_tag"
@@ -11,7 +13,7 @@ const (
 
 // ApplyRules applies merge rules according to _tag, _priority fields, and remove them
 func ApplyRules(m map[string]interface{}) error {
-	err := sortMergeSlices(m)
+	err := sortMergeSlices(m, "")
 	if err != nil {
 		return err
 	}
@@ -20,22 +22,22 @@ func ApplyRules(m map[string]interface{}) error {
 }
 
 // sortMergeSlices enumerates all slices in a map, to sort by priority and merge by tag
-func sortMergeSlices(target map[string]interface{}) error {
+func sortMergeSlices(target map[string]interface{}, path string) error {
 	for key, value := range target {
 		if slice, ok := value.([]interface{}); ok {
 			sortByPriority(slice)
-			s, err := mergeSameTag(slice)
+			s, err := mergeSameTag(slice, fmt.Sprintf("%s.%s", path, key))
 			if err != nil {
 				return err
 			}
 			target[key] = s
 			for _, item := range s {
 				if m, ok := item.(map[string]interface{}); ok {
-					sortMergeSlices(m)
+					sortMergeSlices(m, fmt.Sprintf("%s.%s[]", path, key))
 				}
 			}
 		} else if field, ok := value.(map[string]interface{}); ok {
-			sortMergeSlices(field)
+			sortMergeSlices(field, fmt.Sprintf("%s.%s", path, key))
 		}
 	}
 	return nil
diff --git a/infra/conf/merge/tag.go b/infra/conf/merge/tag.go
index 6c2632e7..3850d551 100644
--- a/infra/conf/merge/tag.go
+++ b/infra/conf/merge/tag.go
@@ -4,10 +4,16 @@
 
 package merge
 
-func getTag(v map[string]interface{}) string {
-	if field, ok := v["tag"]; ok {
-		if t, ok := field.(string); ok {
-			return t
+import (
+	"strings"
+)
+
+func getTag(v map[string]interface{}, tagKeyOnly bool) string {
+	if !tagKeyOnly {
+		if field, ok := v["tag"]; ok {
+			if t, ok := field.(string); ok {
+				return t
+			}
 		}
 	}
 	if field, ok := v[tagKey]; ok {
@@ -18,16 +24,21 @@ func getTag(v map[string]interface{}) string {
 	return ""
 }
 
-func mergeSameTag(s []interface{}) ([]interface{}, error) {
+func mergeSameTag(s []interface{}, path string) ([]interface{}, error) {
 	// from: [a,"",b,"",a,"",b,""]
 	// to: [a,"",b,"",merged,"",merged,""]
 	merged := &struct{}{}
+	tagKeyOnly := false
+	// See https://github.com/v2fly/v2ray-core/issues/2981
+	if strings.HasPrefix(path, ".dns.servers") {
+		tagKeyOnly = true
+	}
 	for i, item1 := range s {
 		map1, ok := item1.(map[string]interface{})
 		if !ok {
 			continue
 		}
-		tag1 := getTag(map1)
+		tag1 := getTag(map1, tagKeyOnly)
 		if tag1 == "" {
 			continue
 		}
@@ -36,7 +47,7 @@ func mergeSameTag(s []interface{}) ([]interface{}, error) {
 			if !ok {
 				continue
 			}
-			tag2 := getTag(map2)
+			tag2 := getTag(map2, tagKeyOnly)
 			if tag1 == tag2 {
 				s[j] = merged
 				err := mergeMaps(map1, map2)
diff --git a/infra/conf/v4/v2ray.go b/infra/conf/v4/v2ray.go
index 399fdc22..9ca662d1 100644
--- a/infra/conf/v4/v2ray.go
+++ b/infra/conf/v4/v2ray.go
@@ -1,7 +1,9 @@
 package v4
 
 import (
+	"context"
 	"encoding/json"
+	"fmt"
 	"path/filepath"
 	"strings"
 
@@ -21,6 +23,7 @@ import (
 	"github.com/v2fly/v2ray-core/v5/infra/conf/synthetic/dns"
 	"github.com/v2fly/v2ray-core/v5/infra/conf/synthetic/log"
 	"github.com/v2fly/v2ray-core/v5/infra/conf/synthetic/router"
+	"github.com/v2fly/v2ray-core/v5/infra/conf/v5cfg"
 )
 
 var (
@@ -481,15 +484,12 @@ func (c *Config) Build() (*core.Config, error) {
 
 	// Load Additional Services that do not have a json translator
 
-	if msg, err := c.BuildServices(c.Services); err != nil {
-		developererr := newError("Loading a V2Ray Features as a service is intended for developers only. " +
-			"This is used for developers to prototype new features or for an advanced client to use special features in V2Ray," +
-			" instead of allowing end user to enable it without special tool and knowledge.")
-		sb := strings.Builder{}
-		return nil, newError("Cannot load service").Base(developererr).Base(err).Base(newError(sb.String()))
-	} else { // nolint: revive
-		// Using a else here is required to keep msg in scope
-		config.App = append(config.App, msg...)
+	for serviceName, service := range c.Services {
+		servicePackedConfig, err := v5cfg.LoadHeterogeneousConfigFromRawJSON(context.Background(), "service", serviceName, *service)
+		if err != nil {
+			return nil, newError(fmt.Sprintf("failed to parse %v config in Services", serviceName)).Base(err)
+		}
+		config.App = append(config.App, serial.ToTypedMessage(servicePackedConfig))
 	}
 
 	var inbounds []InboundDetourConfig


Test patch:
diff --git a/infra/conf/merge/merge_test.go b/infra/conf/merge/merge_test.go
index 6f393a9e..331d03b7 100644
--- a/infra/conf/merge/merge_test.go
+++ b/infra/conf/merge/merge_test.go
@@ -78,7 +78,7 @@ func TestMergeTag(t *testing.T) {
 				"outboundTag": "out-2"
 			}]
 		}
-	}	
+	}
 `
 	expected := `
 	{
@@ -194,6 +194,108 @@ func TestMergeTagDeep(t *testing.T) {
 	assertResult(t, m, expected)
 }
 
+func TestNoMergeDnsServers(t *testing.T) {
+	json1 := `
+	{
+		"dns": {
+			"queryStrategy": "UseIPv4",
+			"fallbackStrategy": "disabled-if-any-match",
+			"domainMatcher": "mph",
+			"servers": [
+				{
+					"address": "aaa.bbb.ccc.ddd",
+					"port": 53,
+					"domains": [
+						"geosite:cn"
+					],
+					"tag": "dns-domestic"
+				},
+				{
+					"address": "114.114.114.114",
+					"port": 53,
+					"domains": [
+						"geosite:cn"
+					],
+					"tag": "dns-domestic"
+				},
+				{
+					"address": "https://1.1.1.1/dns-query",
+					"tag": "dns-international"
+				}
+			]
+		},
+		"routing": {
+			"domainStrategy": "IPIfNonMatch",
+			"domainMatcher": "mph",
+			"rules": [
+				{
+					"type": "field",
+					"inboundTag": "dns-domestic",
+					"outboundTag": "direct"
+				},
+				{
+					"type": "field",
+					"inboundTag": "dns-international",
+					"outboundTag": "proxy"
+				}
+			]
+		}
+	}
+`
+	expected := `
+	{
+		"dns": {
+			"queryStrategy": "UseIPv4",
+			"fallbackStrategy": "disabled-if-any-match",
+			"domainMatcher": "mph",
+			"servers": [
+				{
+					"address": "aaa.bbb.ccc.ddd",
+					"port": 53,
+					"domains": [
+						"geosite:cn"
+					],
+					"tag": "dns-domestic"
+				},
+				{
+					"address": "114.114.114.114",
+					"port": 53,
+					"domains": [
+						"geosite:cn"
+					],
+					"tag": "dns-domestic"
+				},
+				{
+					"address": "https://1.1.1.1/dns-query",
+					"tag": "dns-international"
+				}
+			]
+		},
+		"routing": {
+			"domainStrategy": "IPIfNonMatch",
+			"domainMatcher": "mph",
+			"rules": [
+				{
+					"type": "field",
+					"inboundTag": "dns-domestic",
+					"outboundTag": "direct"
+				},
+				{
+					"type": "field",
+					"inboundTag": "dns-international",
+					"outboundTag": "proxy"
+				}
+			]
+		}
+	}
+`
+	m, err := merge.JSONs([][]byte{[]byte(json1)})
+	if err != nil {
+		t.Error(err)
+	}
+	assertResult(t, m, expected)
+}
+
 func assertResult(t *testing.T, value []byte, expected string) {
 	v := make(map[string]interface{})
 	err := serial.DecodeJSON(bytes.NewReader(value), &v)