Files
3x-ui/internal/web/service/xray_setting.go
T
Volov Vyacheslav e424cc0f4d fix(routing): allow dns.servers on private IPs past the geoip:private block rule (#5774)
* 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>
2026-07-08 20:28:11 +02:00

306 lines
8.9 KiB
Go

package service
import (
_ "embed"
"encoding/base64"
"encoding/json"
"slices"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// XraySettingService provides business logic for Xray configuration management.
// It handles validation and storage of Xray template configurations.
type XraySettingService struct {
SettingService
}
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
// The frontend round-trips the whole getXraySetting response back
// through the textarea, so if it has ever received a wrapped
// payload (see UnwrapXrayTemplateConfig) it sends that same wrapper
// back here. Strip it before validation/storage, otherwise we save
// garbage the next read can't recover from without this same call.
newXraySettings = UnwrapXrayTemplateConfig(newXraySettings)
if err := s.CheckXrayConfig(newXraySettings); err != nil {
return err
}
if hoisted, err := EnsureStatsRouting(newXraySettings); err == nil {
newXraySettings = hoisted
}
if synced, err := EnsureDnsServerRouting(newXraySettings); err == nil {
newXraySettings = synced
}
return s.saveSetting("xrayTemplateConfig", newXraySettings)
}
func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
xrayConfig := &xray.Config{}
err := json.Unmarshal([]byte(XrayTemplateConfig), xrayConfig)
if err != nil {
return common.NewError("xray template config invalid:", err)
}
return nil
}
func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]any) error {
template, err := s.GetXrayConfigTemplate()
if err != nil {
return err
}
var cfg map[string]any
if err := json.Unmarshal([]byte(template), &cfg); err != nil {
return err
}
outbounds, ok := cfg["outbounds"].([]any)
if !ok {
return nil
}
updated := false
for _, outIface := range outbounds {
out, ok := outIface.(map[string]any)
if !ok {
continue
}
if tag, ok := out["tag"].(string); ok && tag == "warp" {
settings, ok := out["settings"].(map[string]any)
if !ok {
continue
}
settings["secretKey"] = warpData["private_key"]
if conf, ok := warpConfig["config"].(map[string]any); ok {
if iface, ok := conf["interface"].(map[string]any); ok {
if addrs, ok := iface["addresses"].(map[string]any); ok {
var addrList []string
if v4, ok := addrs["v4"].(string); ok && v4 != "" {
addrList = append(addrList, v4+"/32")
}
if v6, ok := addrs["v6"].(string); ok && v6 != "" {
addrList = append(addrList, v6+"/128")
}
settings["address"] = addrList
}
}
var clientId string
if id, ok := conf["client_id"].(string); ok {
clientId = id
} else if id, ok := warpData["client_id"]; ok {
clientId = id
}
if clientId != "" {
decoded, _ := base64.StdEncoding.DecodeString(clientId)
var res []int
for _, b := range decoded {
res = append(res, int(b))
}
settings["reserved"] = res
}
if peers, ok := conf["peers"].([]any); ok && len(peers) > 0 {
if peer, ok := peers[0].(map[string]any); ok {
if pSettings, ok := settings["peers"].([]any); ok && len(pSettings) > 0 {
if pSet, ok := pSettings[0].(map[string]any); ok {
pSet["publicKey"] = peer["public_key"]
if endpoint, ok := peer["endpoint"].(map[string]any); ok {
pSet["endpoint"] = endpoint["host"]
}
}
}
}
}
}
updated = true
break
}
}
if updated {
outJSON, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return s.SaveXraySetting(string(outJSON))
}
return nil
}
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
// "xraySetting": <real config> }` response-shaped wrappers that may have
// ended up in the database.
//
// How it got there: getXraySetting used to embed the raw DB value as
// `xraySetting` in its response without checking whether the stored
// value was already that exact response shape. If the frontend then
// saved it verbatim (the textarea is a round-trip of the JSON it was
// handed), the wrapper got persisted — and each subsequent save nested
// another layer, producing the blank Xray Settings page reported in
// issue #4059.
//
// If `raw` does not look like a wrapper, it is returned unchanged.
func UnwrapXrayTemplateConfig(raw string) string {
const maxDepth = 8 // defensive cap against pathological multi-nest values
for range maxDepth {
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &top); err != nil {
return raw
}
inner, ok := top["xraySetting"]
if !ok {
return raw
}
// Real xray configs never contain a top-level "xraySetting" key,
// but they do contain things like "inbounds"/"outbounds"/"api".
// If any of those are present, we're already at the real config
// and the "xraySetting" field is either user data or coincidence
// — don't touch it.
for _, k := range []string{"inbounds", "outbounds", "routing", "api", "dns", "log", "policy", "stats"} {
if _, hit := top[k]; hit {
return raw
}
}
// Peel off one layer.
unwrapped := string(inner)
// `xraySetting` may be stored either as a JSON object or as a
// JSON-encoded string of an object. Handle both.
var asStr string
if err := json.Unmarshal(inner, &asStr); err == nil {
unwrapped = asStr
}
raw = unwrapped
}
return raw
}
// EnsureStatsRouting hoists the `api -> api` routing rule to the front
// of routing.rules so the stats query path is never starved by a
// catch-all rule the admin may have added or reordered above it.
//
// Why this matters (#4113, #2818): an admin who adds a cascade outbound
// (e.g. vless to another server) and a routing rule sending all inbound
// traffic to it ends up sending the internal stats inbound's traffic to
// that cascade too, since rules are evaluated top-to-bottom and the
// catch-all matches first. The panel's gRPC stats query then can't reach
// the running xray instance, GetTraffic returns nothing, and every
// client appears offline with zero traffic even though the actual proxy
// path works fine.
//
// The api inbound is special-cased internal infrastructure for the
// panel, not something the admin should ever route to a real outbound.
// Keeping its rule pinned at index 0 is the only correct configuration.
//
// If the api rule is already at index 0 the input is returned unchanged.
// If it exists somewhere else it is moved. If it is missing entirely a
// default rule (`type=field, inboundTag=[api], outboundTag=api`) is
// inserted at the front. Other routing entries keep their relative order.
func EnsureStatsRouting(raw string) (string, error) {
var cfg map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return raw, err
}
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 {
routing = make(map[string]json.RawMessage)
}
var rules []map[string]any
if r, ok := routing["rules"]; ok && len(r) > 0 {
if err := json.Unmarshal(r, &rules); err != nil {
return raw, err
}
}
apiIdx := findApiRule(rules)
if apiIdx == 0 {
return raw, nil // already correct, don't churn the JSON
}
var apiRule map[string]any
if apiIdx > 0 {
apiRule = rules[apiIdx]
rules = append(rules[:apiIdx], rules[apiIdx+1:]...)
} else {
apiRule = map[string]any{
"type": "field",
"inboundTag": []string{"api"},
"outboundTag": "api",
}
}
delete(apiRule, "enabled")
rules = append([]map[string]any{apiRule}, rules...)
rulesJSON, err := json.Marshal(rules)
if err != nil {
return raw, err
}
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
}
// isApiRule reports whether a routing rule targets the internal api inbound
// (inboundTag contains "api" and outboundTag is "api").
func isApiRule(rule map[string]any) bool {
if outTag, _ := rule["outboundTag"].(string); outTag != "api" {
return false
}
raw, ok := rule["inboundTag"]
if !ok {
return false
}
// inboundTag is usually []string but can come as []any from a
// roundtrip through map[string]any. Accept both shapes.
switch tags := raw.(type) {
case []any:
for _, t := range tags {
if s, ok := t.(string); ok && s == "api" {
return true
}
}
case []string:
if slices.Contains(tags, "api") {
return true
}
case string:
if tags == "api" {
return true
}
}
return false
}
// findApiRule returns the index of the routing rule that targets the
// internal api inbound, or -1 if no such rule exists.
func findApiRule(rules []map[string]any) int {
for i, rule := range rules {
if isApiRule(rule) {
return i
}
}
return -1
}