mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
33782bada7
2 changed files with 235 additions and 0 deletions
|
|
@ -24,6 +24,9 @@ func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
|
|||
if err := s.CheckXrayConfig(newXraySettings); err != nil {
|
||||
return err
|
||||
}
|
||||
if hoisted, err := EnsureStatsRouting(newXraySettings); err == nil {
|
||||
newXraySettings = hoisted
|
||||
}
|
||||
return s.SettingService.saveSetting("xrayTemplateConfig", newXraySettings)
|
||||
}
|
||||
|
||||
|
|
@ -83,3 +86,120 @@ func UnwrapXrayTemplateConfig(raw string) string {
|
|||
}
|
||||
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",
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// findApiRule returns the index of the routing rule that targets the
|
||||
// internal api inbound (inboundTag contains "api" and outboundTag is
|
||||
// "api"), or -1 if no such rule exists.
|
||||
func findApiRule(rules []map[string]any) int {
|
||||
for i, rule := range rules {
|
||||
if outTag, _ := rule["outboundTag"].(string); outTag != "api" {
|
||||
continue
|
||||
}
|
||||
raw, ok := rule["inboundTag"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 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 i
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, s := range tags {
|
||||
if s == "api" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
case string:
|
||||
if tags == "api" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,3 +88,118 @@ func equalJSON(t *testing.T, a, b string) bool {
|
|||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// firstRuleOutbound parses the (post-hoisted) config and returns
|
||||
// routing.rules[0].outboundTag, or "" if anything is missing.
|
||||
func firstRuleOutbound(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal cfg: %v", err)
|
||||
}
|
||||
routing, _ := cfg["routing"].(map[string]any)
|
||||
rules, _ := routing["rules"].([]any)
|
||||
if len(rules) == 0 {
|
||||
return ""
|
||||
}
|
||||
first, _ := rules[0].(map[string]any)
|
||||
tag, _ := first["outboundTag"].(string)
|
||||
return tag
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_HoistsApiRuleFromMiddle(t *testing.T) {
|
||||
// #4113 repro shape: admin added a cascade outbound and put a
|
||||
// catch-all routing rule above the api rule. stats query path
|
||||
// gets starved by the catch-all unless we hoist the api rule.
|
||||
in := `{
|
||||
"routing": {
|
||||
"rules": [
|
||||
{"type":"field","inboundTag":["inbound-vless"],"outboundTag":"vless-cascade"},
|
||||
{"type":"field","inboundTag":["api"],"outboundTag":"api"},
|
||||
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
|
||||
]
|
||||
}
|
||||
}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("api rule should be at index 0 after hoist, got first outboundTag = %q\nfull: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_NoOpWhenAlreadyFirst(t *testing.T) {
|
||||
// Don't churn the JSON when nothing needs fixing — same string in,
|
||||
// same string out. Lets the diff in the panel UI stay quiet for
|
||||
// well-formed configs.
|
||||
in := `{"routing":{"rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"},{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("expected unchanged input, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_InsertsDefaultWhenMissing(t *testing.T) {
|
||||
// Some admins delete the api rule by accident. Re-add a default
|
||||
// at the front so stats keep working after the next save.
|
||||
in := `{"routing":{"rules":[{"type":"field","outboundTag":"vless-cascade","inboundTag":["inbound-vless"]}]}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("default api rule should be inserted at index 0, got %q\nfull: %s", got, out)
|
||||
}
|
||||
// The original rule should still be there, just shifted.
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(out), &cfg)
|
||||
rules := cfg["routing"].(map[string]any)["rules"].([]any)
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("expected 2 rules after insert, got %d: %v", len(rules), rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_NoRoutingBlock(t *testing.T) {
|
||||
// Pathological but possible: empty config or one without a routing
|
||||
// section. Don't crash, and create the section with the api rule.
|
||||
in := `{"log":{}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("api rule should be created when routing was missing, got %q\nfull: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_InvalidJsonReturnsAsIs(t *testing.T) {
|
||||
// SaveXraySetting calls CheckXrayConfig before this helper, so
|
||||
// invalid JSON shouldn't reach us in practice — but be defensive
|
||||
// about garbage in (return same garbage out plus an error) so the
|
||||
// caller can choose to skip the hoist instead of corrupting input.
|
||||
in := "definitely not json"
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid json, got none")
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("expected raw passthrough on error, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_AcceptsInboundTagAsString(t *testing.T) {
|
||||
// Some manually-edited configs use a single string instead of an
|
||||
// array for inboundTag. Make sure we still recognize the api rule.
|
||||
in := `{"routing":{"rules":[{"type":"field","inboundTag":["other"],"outboundTag":"vless-cascade"},{"type":"field","inboundTag":"api","outboundTag":"api"}]}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("api rule with string-form inboundTag should hoist to front, got %q\nfull: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue