fix(xray): allow a private DoH server on the port Xray dials (#6918)

The DNS allow rule opened port 53 for a private server given as https://
or h2c://, but Xray dials remote DoH on the URL's port, 443 by default,
so the rule never matched and the private-IP block dropped the queries.

Remote DoH now takes its port from the URL and ignores an object entry's
port field, which Xray ignores as well; the +local forms skip routing and
keep their previous handling.
This commit is contained in:
Jay Hemnani
2026-10-24 23:10:00 +05:30
committed by GitHub
parent f105132866
commit 180cc37503
2 changed files with 61 additions and 17 deletions
@@ -130,16 +130,8 @@ type dnsServerEndpoint struct {
port int
}
// privateDnsServerEndpoint extracts a literal, private/internal (ip, port)
// endpoint from a dns.servers entry, or ok=false if the entry is a domain
// name, a special Xray keyword (localhost, fakedns, ...), or resolves to a
// public IP.
//
// A dns.servers entry is either a bare string or an object with an
// "address" field (see frontend/src/schemas/dns.ts DnsServerEntrySchema);
// the object form may also carry an explicit "port" (default 53 there,
// per DnsServerObjectInnerSchema), which takes precedence over any port
// embedded in the address itself.
// privateDnsServerEndpoint returns the private (ip, port) of a dns.servers string or object entry; an
// object's "port" wins over the address's own except for remote DoH, which Xray dials from its URL.
func privateDnsServerEndpoint(entry any) (dnsServerEndpoint, bool) {
var address string
explicitPort := 0
@@ -159,7 +151,7 @@ func privateDnsServerEndpoint(entry any) (dnsServerEndpoint, bool) {
if host == "" {
return dnsServerEndpoint{}, false
}
if explicitPort > 0 {
if explicitPort > 0 && !isRemoteDoh(address) {
port = explicitPort
}
@@ -175,18 +167,26 @@ func privateDnsServerEndpoint(entry any) (dnsServerEndpoint, bool) {
return dnsServerEndpoint{ip: ip.String(), port: port}, true
}
// splitAddressHostPort extracts the bare host and port (defaulting to 53)
// from an Xray-core DNS server address string. Those may carry a URI
// scheme (tcp://, tcp+local://, https://, https+local://, quic://,
// quic+local://) and, for DoH, a path and/or a bracketed IPv6 host — all
// of that is stripped down to host[:port] before parsing.
// isRemoteDoh reports a routed DoH address (https://, h2c://), which Xray dials from the URL alone.
func isRemoteDoh(address string) bool {
scheme, _, ok := strings.Cut(strings.TrimSpace(address), "://")
return ok && (strings.EqualFold(scheme, "https") || strings.EqualFold(scheme, "h2c"))
}
// splitAddressHostPort strips an Xray DNS address's scheme, DoH path and IPv6 brackets down to host
// and port, defaulting to 443 for remote DoH (https://, h2c://) and 53 otherwise.
func splitAddressHostPort(address string) (host string, port int) {
address = strings.TrimSpace(address)
if address == "" {
return "", 0
}
port = 53
if idx := strings.Index(address, "://"); idx != -1 {
// Remote DoH goes out on the HTTPS port, h2c included; the +local forms skip routing.
if isRemoteDoh(address) {
port = 443
}
address = address[idx+3:]
}
// Drop a DoH path, e.g. "1.1.1.1/dns-query".
@@ -194,7 +194,6 @@ func splitAddressHostPort(address string) (host string, port int) {
address = address[:idx]
}
port = 53
host = address
if strings.HasPrefix(host, "[") {
// Bracketed IPv6, with or without a port: "[::1]" / "[::1]:53".
@@ -183,6 +183,34 @@ func TestEnsureDnsServerRouting_StripsSchemePortAndPath(t *testing.T) {
}
}
func TestEnsureDnsServerRouting_RemoteDohUsesHttpsPort(t *testing.T) {
in := `{
"dns": {"servers": [
"https://10.255.255.1/dns-query",
"h2c://10.255.255.2/dns-query",
"https://10.255.255.3:8443/dns-query"
]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 3 {
t.Fatalf("got %d rules, want 2 allow rules + block: %v", len(rules), rules)
}
if p, _ := rules[0]["port"].(string); p != "443" {
t.Fatalf("rules[0] port = %v, want \"443\"", rules[0]["port"])
}
if ips := readRuleIPs(rules[0]["ip"]); len(ips) != 2 || ips[0] != "10.255.255.1" || ips[1] != "10.255.255.2" {
t.Fatalf("rules[0] ip = %v, want [10.255.255.1 10.255.255.2]", ips)
}
if p, _ := rules[1]["port"].(string); p != "8443" {
t.Fatalf("rules[1] port = %v, want \"8443\"", rules[1]["port"])
}
}
func TestEnsureDnsServerRouting_SkipsSpecialAndDomainAddresses(t *testing.T) {
in := `{
"dns": {"servers": ["localhost", "fakedns", "dns.google", "8.8.8.8"]},
@@ -465,3 +493,20 @@ func TestEnsureDnsServerRouting_InvalidJsonReturnsAsIs(t *testing.T) {
t.Fatalf("expected raw passthrough on error, got %q", out)
}
}
// Xray dials a DoH server from its URL alone; the object's "port" (53 in entries saved by older
// forms) never reaches it, so the allow rule must still open the URL's port.
func TestEnsureDnsServerRouting_RemoteDohIgnoresObjectPort(t *testing.T) {
in := `{
"dns": {"servers": [{"address": "https://10.255.255.4/dns-query", "port": 53, "domains": ["lan"]}]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if p, _ := rules[0]["port"].(string); p != "443" {
t.Fatalf("rules[0] port = %v, want \"443\"", rules[0]["port"])
}
}