mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +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 {
|
if err := s.CheckXrayConfig(newXraySettings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if hoisted, err := EnsureStatsRouting(newXraySettings); err == nil {
|
||||||
|
newXraySettings = hoisted
|
||||||
|
}
|
||||||
return s.SettingService.saveSetting("xrayTemplateConfig", newXraySettings)
|
return s.SettingService.saveSetting("xrayTemplateConfig", newXraySettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,3 +86,120 @@ func UnwrapXrayTemplateConfig(raw string) string {
|
||||||
}
|
}
|
||||||
return raw
|
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)
|
jb, _ := json.Marshal(vb)
|
||||||
return string(ja) == string(jb)
|
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