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>
306 lines
8.9 KiB
Go
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
|
|
}
|