mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-09 14:16:07 +00:00
e424cc0f4d
* fix(routing): allow dns.servers on private IPs past the geoip:private block rule Xray's own DNS client traffic is dispatched through the same routing table as proxied client traffic. When dns.servers points at a private IP (e.g. a self-hosted AdGuard Home / Pi-hole reachable on the same Docker network as Xray) and the panel's default geoip:private block rule is active, Xray's own DNS lookups get silently dropped. Xray then falls back to dialing destinations by raw hostname once its internal DNS attempt times out (~4s), so proxied connections still work, just with a multi-second stall added to every new domain-based connection, with no error surfaced anywhere. EnsureDnsServerRouting keeps a managed "direct" allow-rule for any private literal IP found in dns.servers, inserted immediately before the geoip:private block rule (matched by shape, not position). It only acts when both ingredients are present, keeps the managed rule in sync as dns.servers changes across saves, and never touches manually authored rules. Fixes #5773 * fix(routing): scope the DNS allow-rule to its port, guard against reorder/UI drift Addresses three review findings on the initial fix: 1. The allow-rule now carries a "port" matcher (grouped by the dns.servers entries that share it), instead of opening every port on the private DNS IP to proxy-client traffic. A private resolver that also exposes an unauthenticated admin UI on the same address would otherwise become reachable through the proxy too. 2. EnsureDnsServerRouting now strips every previously-managed rule and rebuilds the current set fresh, reinserted immediately before the (re-indexed) geoip:private block rule on every save. Comparing IP content alone missed the case where an admin drags the rule below the block rule in the Routing tab (or reorders something else and incidentally moves it) — silently reintroducing the exact stall this fix addresses, with nothing to notice or correct it. 3. dnsAllowRuleShape now tolerates an "enabled" key as long as it's true, matching the existing EnsureStatsRouting precedent (xray_setting.go's `delete(apiRule, "enabled")`). The Routing tab's rule editor writes that key on every save regardless of whether anything changed, and its enabled switch writes it on a plain toggle — without this, either action permanently disowns the rule from management and a duplicate gets inserted next save. A rule explicitly disabled (enabled=false) is left alone and a fresh one is (re-)created, respecting the admin's choice instead of silently re-enabling it. No-op detection now compares rebuilt rules against the original routing.rules JSON (both decoded through encoding/json to a common type) rather than reflect.DeepEqual on the parsed Go values, which falsely reported changes for identical content stored as []any vs []string. 5 new tests cover multi-port grouping, position drift, and both enabled-key cases; existing tests updated for the port field. * fix: avoid size-computation overflow in allocation hint CodeQL flagged make([]map[string]any, 0, len(clean)+len(managed)) as a potential integer-overflow risk in the capacity computation. Drop the addition and hint with len(clean) alone — it already covers most of the eventual size, and append still grows correctly for the rest. --------- Co-authored-by: Volov <volovdata@google.com>
387 lines
12 KiB
Go
387 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
|
)
|
|
|
|
// dnsAllowRuleShape identifies routing rules this file manages: a plain
|
|
// "type=field, ip=[...], port=..., outboundTag=direct" rule with no other
|
|
// matchers. An "enabled" key is tolerated as long as it's true — the
|
|
// Routing tab's rule editor (RuleFormModal.tsx submit()) and its enabled
|
|
// switch (RoutingTab.tsx toggleRule()) always write that key back, even
|
|
// when nothing else changed, so requiring its absence would disown the
|
|
// rule the first time an admin so much as opens it in the UI. A rule
|
|
// toggled off (enabled=false) is treated as no longer ours: the admin
|
|
// explicitly turned it off, and re-enabling it on the next save would
|
|
// silently override that choice.
|
|
//
|
|
// Rules shaped like this are kept in sync with the current dns.servers
|
|
// config on every save; anything else (including rules an admin wrote by
|
|
// hand that happen to also allow-list an IP) is left untouched.
|
|
func dnsAllowRuleShape(rule map[string]any) bool {
|
|
if t, _ := rule["type"].(string); t != "field" {
|
|
return false
|
|
}
|
|
if out, _ := rule["outboundTag"].(string); out != "direct" {
|
|
return false
|
|
}
|
|
if _, ok := rule["ip"]; !ok {
|
|
return false
|
|
}
|
|
if _, ok := rule["port"]; !ok {
|
|
return false
|
|
}
|
|
for key := range rule {
|
|
switch key {
|
|
case "type", "outboundTag", "ip", "port":
|
|
continue
|
|
case "enabled":
|
|
if enabled, ok := rule[key].(bool); !ok || !enabled {
|
|
return false
|
|
}
|
|
continue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// findPrivateBlockRule returns the index of a routing rule that blocks
|
|
// geoip:private (the panel's default anti-SSRF rule), or -1 if none is
|
|
// present. Matched by shape (outboundTag=blocked, ip contains
|
|
// "geoip:private") rather than position, since admins can reorder rules.
|
|
func findPrivateBlockRule(rules []map[string]any) int {
|
|
for i, rule := range rules {
|
|
if out, _ := rule["outboundTag"].(string); out != "blocked" {
|
|
continue
|
|
}
|
|
for _, ip := range readRuleIPs(rule["ip"]) {
|
|
if strings.EqualFold(ip, "geoip:private") {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func readRuleIPs(raw any) []string {
|
|
switch v := raw.(type) {
|
|
case []string:
|
|
return v
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
case string:
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
return []string{v}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// dnsServerEndpoint is a literal (ip, port) pair extracted from a
|
|
// dns.servers entry.
|
|
type dnsServerEndpoint struct {
|
|
ip string
|
|
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.
|
|
func privateDnsServerEndpoint(entry any) (dnsServerEndpoint, bool) {
|
|
var address string
|
|
explicitPort := 0
|
|
switch v := entry.(type) {
|
|
case string:
|
|
address = v
|
|
case map[string]any:
|
|
address, _ = v["address"].(string)
|
|
if p, ok := v["port"].(float64); ok && p > 0 {
|
|
explicitPort = int(p)
|
|
}
|
|
default:
|
|
return dnsServerEndpoint{}, false
|
|
}
|
|
|
|
host, port := splitAddressHostPort(address)
|
|
if host == "" {
|
|
return dnsServerEndpoint{}, false
|
|
}
|
|
if explicitPort > 0 {
|
|
port = explicitPort
|
|
}
|
|
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
// Domain name, or a special keyword like "localhost"/"fakedns" —
|
|
// neither is something we can safely allow-list by IP here.
|
|
return dnsServerEndpoint{}, false
|
|
}
|
|
if !netsafe.IsBlockedIP(ip) {
|
|
return dnsServerEndpoint{}, false
|
|
}
|
|
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.
|
|
func splitAddressHostPort(address string) (host string, port int) {
|
|
address = strings.TrimSpace(address)
|
|
if address == "" {
|
|
return "", 0
|
|
}
|
|
|
|
if idx := strings.Index(address, "://"); idx != -1 {
|
|
address = address[idx+3:]
|
|
}
|
|
// Drop a DoH path, e.g. "1.1.1.1/dns-query".
|
|
if idx := strings.Index(address, "/"); idx != -1 {
|
|
address = address[:idx]
|
|
}
|
|
|
|
port = 53
|
|
host = address
|
|
if strings.HasPrefix(host, "[") {
|
|
// Bracketed IPv6, with or without a port: "[::1]" / "[::1]:53".
|
|
end := strings.Index(host, "]")
|
|
if end == -1 {
|
|
return host, port
|
|
}
|
|
rest := host[end+1:]
|
|
host = host[1:end]
|
|
if p, ok := strings.CutPrefix(rest, ":"); ok {
|
|
if n, err := strconv.Atoi(p); err == nil {
|
|
port = n
|
|
}
|
|
}
|
|
return host, port
|
|
}
|
|
if h, p, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
if n, err := strconv.Atoi(p); err == nil {
|
|
port = n
|
|
}
|
|
}
|
|
return host, port
|
|
}
|
|
|
|
// dnsAllowPortGroup is the set of private literal IPs that share a single
|
|
// port among the configured dns.servers, e.g. two internal resolvers both
|
|
// queried on :53.
|
|
type dnsAllowPortGroup struct {
|
|
port int
|
|
ips []string
|
|
}
|
|
|
|
// collectPrivateDnsAllowGroups returns the private dns.servers endpoints
|
|
// grouped by port, sorted by port ascending (ips within a group sorted and
|
|
// de-duplicated) for deterministic output.
|
|
func collectPrivateDnsAllowGroups(dnsRaw json.RawMessage) []dnsAllowPortGroup {
|
|
if len(dnsRaw) == 0 {
|
|
return nil
|
|
}
|
|
var dns struct {
|
|
Servers []any `json:"servers"`
|
|
}
|
|
if err := json.Unmarshal(dnsRaw, &dns); err != nil {
|
|
return nil
|
|
}
|
|
|
|
byPort := make(map[int]map[string]bool)
|
|
for _, entry := range dns.Servers {
|
|
ep, ok := privateDnsServerEndpoint(entry)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if byPort[ep.port] == nil {
|
|
byPort[ep.port] = make(map[string]bool)
|
|
}
|
|
byPort[ep.port][ep.ip] = true
|
|
}
|
|
|
|
ports := make([]int, 0, len(byPort))
|
|
for p := range byPort {
|
|
ports = append(ports, p)
|
|
}
|
|
sort.Ints(ports)
|
|
|
|
groups := make([]dnsAllowPortGroup, 0, len(ports))
|
|
for _, p := range ports {
|
|
ips := make([]string, 0, len(byPort[p]))
|
|
for ip := range byPort[p] {
|
|
ips = append(ips, ip)
|
|
}
|
|
sort.Strings(ips)
|
|
groups = append(groups, dnsAllowPortGroup{port: p, ips: ips})
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// EnsureDnsServerRouting keeps a set of managed "direct" allow-rules — one
|
|
// per distinct port among any private/internal dns.servers addresses —
|
|
// in sync, positioned immediately before the panel's default
|
|
// geoip:private block rule.
|
|
//
|
|
// Why this matters: Xray's own DNS client traffic is dispatched through
|
|
// the same routing table as proxied client traffic. If dns.servers points
|
|
// at a private IP (e.g. a self-hosted AdGuard Home / Pi-hole reachable on
|
|
// the same Docker network as Xray — a common self-hosted setup) and the
|
|
// panel's default private-IP block rule is active, Xray's own DNS lookups
|
|
// get silently dropped by that rule. Xray then falls back to dialing
|
|
// destinations by raw hostname once its internal DNS attempt times out
|
|
// (~4s), so proxied connections still work, just with a multi-second stall
|
|
// added to every new domain, with no error surfaced to the client or
|
|
// admin.
|
|
//
|
|
// Each managed rule is scoped to its port (not just the IP), so the
|
|
// exception only reopens the DNS traffic that actually needs it rather
|
|
// than every port on the private host. On every save, all previously
|
|
// managed rules are stripped out and a fresh set is rebuilt from the
|
|
// current dns.servers config and reinserted right before the block rule
|
|
// (recomputing its index after the strip) — this corrects both content
|
|
// drift (dns.servers changed) and position drift (an admin dragged a
|
|
// managed rule below the block rule in the Routing tab, which would
|
|
// otherwise silently reintroduce the stall with nothing to notice or fix
|
|
// it). The rebuilt result is only written back if it actually differs
|
|
// from the input, so well-formed configs aren't churned on every save.
|
|
// Manually-authored rules are never touched — see dnsAllowRuleShape.
|
|
func EnsureDnsServerRouting(raw string) (string, error) {
|
|
var cfg map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
return raw, err
|
|
}
|
|
|
|
groups := collectPrivateDnsAllowGroups(cfg["dns"])
|
|
|
|
var routing map[string]json.RawMessage
|
|
if r, ok := cfg["routing"]; ok && len(r) > 0 {
|
|
if err := json.Unmarshal(r, &routing); err != nil {
|
|
return raw, err
|
|
}
|
|
}
|
|
if routing == nil {
|
|
return raw, nil
|
|
}
|
|
|
|
var original []map[string]any
|
|
if r, ok := routing["rules"]; ok && len(r) > 0 {
|
|
if err := json.Unmarshal(r, &original); err != nil {
|
|
return raw, err
|
|
}
|
|
}
|
|
|
|
rebuilt := rebuildDnsAllowRules(original, groups)
|
|
|
|
rulesJSON, err := json.Marshal(rebuilt)
|
|
if err != nil {
|
|
return raw, err
|
|
}
|
|
|
|
// Compare against the original rules JSON, not the parsed Go values:
|
|
// json.Unmarshal into []map[string]any turns "ip" arrays into []any,
|
|
// while the rules this function builds use []string — those hold
|
|
// identical content but are different types under reflect.DeepEqual,
|
|
// which would otherwise report a no-op input as changed and churn the
|
|
// JSON on every save for no reason.
|
|
origRulesJSON := routing["rules"]
|
|
if len(origRulesJSON) == 0 {
|
|
origRulesJSON = json.RawMessage("[]")
|
|
}
|
|
if jsonEqual(origRulesJSON, rulesJSON) {
|
|
return raw, nil
|
|
}
|
|
|
|
routing["rules"] = rulesJSON
|
|
routingJSON, err := json.Marshal(routing)
|
|
if err != nil {
|
|
return raw, err
|
|
}
|
|
cfg["routing"] = routingJSON
|
|
|
|
out, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
return raw, err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// rebuildDnsAllowRules strips any existing managed rules out of rules,
|
|
// then — if a geoip:private block rule is present and groups is non-empty
|
|
// — reinserts a freshly built managed rule per group immediately before
|
|
// it. This uniformly handles content updates, position drift, and removal
|
|
// (an empty groups list just leaves the managed rules stripped).
|
|
func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []map[string]any {
|
|
clean := make([]map[string]any, 0, len(rules))
|
|
for _, rule := range rules {
|
|
if !dnsAllowRuleShape(rule) {
|
|
clean = append(clean, rule)
|
|
}
|
|
}
|
|
|
|
blockIdx := findPrivateBlockRule(clean)
|
|
if blockIdx < 0 || len(groups) == 0 {
|
|
return clean
|
|
}
|
|
|
|
managed := make([]map[string]any, 0, len(groups))
|
|
for _, g := range groups {
|
|
managed = append(managed, map[string]any{
|
|
"type": "field",
|
|
"ip": g.ips,
|
|
"port": strconv.Itoa(g.port),
|
|
"outboundTag": "direct",
|
|
})
|
|
}
|
|
|
|
// Capacity hint uses len(clean) alone (not len(clean)+len(managed)):
|
|
// summing two independent lengths for a make() size risks overflow on
|
|
// pathological input per static analysis, and clean's length already
|
|
// covers most of the eventual size on its own.
|
|
out := make([]map[string]any, 0, len(clean))
|
|
out = append(out, clean[:blockIdx]...)
|
|
out = append(out, managed...)
|
|
out = append(out, clean[blockIdx:]...)
|
|
return out
|
|
}
|
|
|
|
// jsonEqual reports whether a and b decode to structurally identical
|
|
// values. Used instead of comparing raw bytes (key order, whitespace) or
|
|
// reflect.DeepEqual on already-parsed Go values (which is type-sensitive
|
|
// to []any vs []string and would misreport identical content as changed).
|
|
func jsonEqual(a, b json.RawMessage) bool {
|
|
var av, bv any
|
|
if err := json.Unmarshal(a, &av); err != nil {
|
|
return false
|
|
}
|
|
if err := json.Unmarshal(b, &bv); err != nil {
|
|
return false
|
|
}
|
|
return reflect.DeepEqual(av, bv)
|
|
}
|