package service import ( "encoding/json" "fmt" "path" "path/filepath" "strings" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // XrayService builds the xray configurations this panel hands out. The // lite master is a pure control plane — it never runs a local core (the // P9(4b) round removed the local process lifecycle, the local config // branch and the local-only injections wholesale) — so the only build // left is the per-agent one: the panel template plus the inbounds homed // on that agent, its stored egress override, and the compiled per-inbound // forwarding entries. type XrayService struct { inboundService InboundService settingService SettingService nodeService NodeService } // GetXrayConfigForAgent builds the Xray configuration pushed to a lightweight // agent bound to nodeID: the panel template plus that node's enabled inbounds. // The agent's egress override (outbounds / routing rules JSON, as stored on its // AgentNode row) replaces the template defaults when set; otherwise the // template defaults are kept. func (s *XrayService) GetXrayConfigForAgent(nodeID int, outboundsJSON, routingRulesJSON string) (*xray.Config, error) { return s.buildAgentXrayConfig(nodeID, outboundsJSON, routingRulesJSON) } // isSocksFamilyProtocol reports whether the protocol authenticates with // settings.accounts (user/pass pairs) instead of settings.clients: // "mixed" natively, plus "socks" rows that arrive via node sync from // stock panels (this fork has no native socks inbound form). func isSocksFamilyProtocol(p model.Protocol) bool { return p == model.Mixed || p == model.Protocol("socks") } // applySocksClientAccounts folds the DB clients attached to a // socks-family inbound into its generated settings as accounts. Xray // reports these inbounds' traffic under user>>> counters, // so the account name is the client's email — the identity the panel // books traffic by — and the password is the client's password // (verified against xray-core 26.6.27 stats output). The inbound's own // accounts from the stored settings keep their order and win name // collisions, so the fixed landing account that managed-forwarding // resolution reads from the settings stays first. The settings' // "clients" mirror is dropped from the generated config: the core // ignores it for these protocols and it would only ship the client // records (passwords included) to the agent for nothing. Returns // whether the settings changed. func applySocksClientAccounts(settings map[string]any, clientAccounts []any) bool { mirror, _ := settings["clients"].([]any) if len(mirror) == 0 && len(clientAccounts) == 0 { return false } delete(settings, "clients") existing, _ := settings["accounts"].([]any) seen := make(map[string]bool, len(existing)+len(clientAccounts)) merged := make([]any, 0, len(existing)+len(clientAccounts)) for _, acc := range existing { merged = append(merged, acc) if m, ok := acc.(map[string]any); ok { if user, _ := m["user"].(string); user != "" { seen[user] = true } } } for _, acc := range clientAccounts { m, _ := acc.(map[string]any) user, _ := m["user"].(string) if user == "" || seen[user] { continue } seen[user] = true merged = append(merged, acc) } settings["accounts"] = merged // Attached clients only mean anything under password auth; without // it the core ignores the accounts entirely. if len(clientAccounts) > 0 { settings["auth"] = "password" } return true } func (s *XrayService) buildAgentXrayConfig(nodeID int, outboundsJSON, routingRulesJSON string) (*xray.Config, error) { templateConfig, err := s.settingService.GetXrayConfigTemplate() if err != nil { return nil, err } xrayConfig := &xray.Config{} err = json.Unmarshal([]byte(templateConfig), xrayConfig) if err != nil { return nil, err } xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig) xrayConfig.API = ensureAPIServices(xrayConfig.API) xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy) xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig) // Template outbounds authored before the xray-core #6258 XHTTP rename may // still carry sessionPlacement/sessionKey; lift them too (same reason as // the per-inbound lift below). xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs) inbounds, err := s.inboundService.GetAllInbounds() if err != nil { return nil, err } // Inbounds that made it into this build, kept for the forwarding // compilation below. var includedInbounds []*model.Inbound for _, inbound := range inbounds { if !inbound.Enable { continue } if inbound.NodeID == nil || *inbound.NodeID != nodeID { continue } if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG { continue } settings := map[string]any{} _ = json.Unmarshal([]byte(inbound.Settings), &settings) dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id) if listErr != nil { return nil, listErr } clientStats := inbound.ClientStats enableMap := make(map[string]bool, len(clientStats)) for _, clientTraffic := range clientStats { enableMap[clientTraffic.Email] = clientTraffic.Enable } finalClients := make([]any, 0, len(dbClients)) var wgPeers []any var socksAccounts []any for i := range dbClients { c := dbClients[i] if enable, exists := enableMap[c.Email]; exists && !enable { logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email) continue } if !c.Enable { continue } flow := c.Flow if flow == "xtls-rprx-vision-udp443" { flow = "xtls-rprx-vision" } if inbound.DisableFlow { flow = "" } entry := map[string]any{"email": c.Email} switch inbound.Protocol { case model.VLESS: if c.ID != "" { entry["id"] = c.ID } if flow != "" { entry["flow"] = flow } if c.Reverse != nil { entry["reverse"] = c.Reverse } case model.VMESS: if c.ID != "" { entry["id"] = c.ID } if c.Security != "" { entry["security"] = c.Security } case model.Trojan: if c.Password != "" { entry["password"] = c.Password } if flow != "" { entry["flow"] = flow } case model.Shadowsocks: if c.Password != "" { entry["password"] = c.Password } case model.Hysteria: if c.Auth != "" { entry["auth"] = c.Auth } case model.Mixed, model.Protocol("socks"): // Socks-family inbounds authenticate against // settings.accounts, not settings.clients: one account // per client, named by the client email — xray keys // its per-account traffic counters by that name // (user>>>), which is the identity the // master books traffic by. socksAccounts = append(socksAccounts, map[string]any{ "user": c.Email, "pass": c.Password, }) continue case model.WireGuard: wgPeers = append(wgPeers, model.WireguardPeerFromClient(c)) continue } finalClients = append(finalClients, entry) } var mutated bool if isSocksFamilyProtocol(inbound.Protocol) { if applySocksClientAccounts(settings, socksAccounts) { mutated = true } } else if inbound.Protocol == model.WireGuard { delete(settings, "clients") if wgPeers == nil { wgPeers = []any{} } settings["peers"] = wgPeers mutated = true } else { _, hadClients := settings["clients"] // Don't clobber settings clients with an empty DB result; // the DB is the source of truth only when it has rows. if len(finalClients) > 0 { settings["clients"] = finalClients } else if !hadClients { settings["clients"] = finalClients } } if inboundCanHostFallbacks(inbound) { fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id) if fbErr != nil { return nil, fbErr } if len(fallbacks) > 0 { generic := make([]any, 0, len(fallbacks)) for _, f := range fallbacks { generic = append(generic, f) } settings["fallbacks"] = generic mutated = true } } if mutated { modifiedSettings, err := json.MarshalIndent(settings, "", " ") if err != nil { return nil, err } inbound.Settings = string(modifiedSettings) } if len(inbound.StreamSettings) > 0 { // Unmarshal stream JSON var stream map[string]any _ = json.Unmarshal([]byte(inbound.StreamSettings), &stream) // Remove the "settings" field under "tlsSettings" and "realitySettings" tlsSettings, ok1 := stream["tlsSettings"].(map[string]any) realitySettings, ok2 := stream["realitySettings"].(map[string]any) if ok1 || ok2 { if ok1 { delete(tlsSettings, "settings") } else if ok2 { delete(realitySettings, "settings") } } delete(stream, "externalProxy") // finalmask.tcp + REALITY panics Xray-core on the first connection // (XTLS/Xray-core#6453). AddInbound/UpdateInbound reject this // combination at save time, but a row saved before that guard // existed (upgrade, node sync, restored backup, direct DB edit) // would still crash Xray on the next restart without this — drop // it here too, the same way liftXhttpSessionIDKeys and // HealShadowsocksClientMethods heal other legacy data in place. if len(finalMaskRealityTcpMasks(stream)) > 0 { logger.Warningf("Inbound %q: dropping finalmask, incompatible with REALITY security (crashes Xray-core, see XTLS/Xray-core#6453)", inbound.Tag) delete(stream, "finalmask") } dropEmptyRandPackets(stream["finalmask"]) if dropped := stripIncompleteXmcMasks(stream); dropped > 0 { logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped) } // xray-core v26.6.22 (#6258) renamed the XHTTP session keys and // kept no fallback. Lift legacy sessionPlacement/sessionKey onto the // new names here so inbounds stored before the rename keep working // without the admin re-saving them. liftXhttpSessionIDKeys(stream) newStream, err := json.MarshalIndent(stream, "", " ") if err != nil { return nil, err } inbound.StreamSettings = string(newStream) } if inbound.Protocol == model.Shadowsocks { if healed, ok := model.HealShadowsocksClientMethods(inbound.Settings); ok { inbound.Settings = healed } } inboundConfig := inbound.GenXrayInboundConfig() xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig) includedInbounds = append(includedInbounds, inbound) } // The panel is the only source of truth for egress, so the agent's // stored outbounds/routing rules replace the template defaults. if err := applyAgentEgress(xrayConfig, outboundsJSON, routingRulesJSON); err != nil { return nil, err } // Per-inbound forwarding forms compile on top of that base, freshly // on every pull — the template stays the single source for the shared // outbounds and no egress snapshot is ever stored. s.applyInboundForwarding(xrayConfig, includedInbounds) return xrayConfig, nil } var agentAPIRoutingRule = map[string]any{ "type": "field", "inboundTag": []any{"api"}, "outboundTag": "api", } // applyAgentEgress replaces the template's outbounds and routing rules with the // agent's stored egress configuration. Empty inputs keep the template // defaults. The stats-API routing rule is always ensured. func applyAgentEgress(cfg *xray.Config, outboundsJSON, routingRulesJSON string) error { if outboundsJSON != "" { var outbounds []any if err := json.Unmarshal([]byte(outboundsJSON), &outbounds); err != nil { return fmt.Errorf("invalid agent outbounds JSON: %w", err) } raw, err := json.Marshal(outbounds) if err != nil { return err } cfg.OutboundConfigs = raw } var routing map[string]any if len(cfg.RouterConfig) > 0 { if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil { return fmt.Errorf("invalid template routing JSON: %w", err) } } else { routing = map[string]any{} } if routingRulesJSON != "" { var rules []any if err := json.Unmarshal([]byte(routingRulesJSON), &rules); err != nil { return fmt.Errorf("invalid agent routing rules JSON: %w", err) } routing["rules"] = rules } routing["rules"] = ensureAPIRoutingRule(routing["rules"]) raw, err := json.Marshal(routing) if err != nil { return err } cfg.RouterConfig = raw return nil } // ensureAPIRoutingRule prepends the stats-API rule when no rule already // matches the "api" inbound tag. func ensureAPIRoutingRule(rules any) []any { list, _ := rules.([]any) for _, r := range list { m, ok := r.(map[string]any) if !ok { continue } for _, tag := range inboundTagsOf(m["inboundTag"]) { if tag == "api" { return list } } } return append([]any{agentAPIRoutingRule}, list...) } func inboundTagsOf(v any) []string { switch t := v.(type) { case string: return []string{t} case []any: var out []string for _, e := range t { if s, ok := e.(string); ok { out = append(out, s) } } return out default: return nil } } // ensureAPIServices guarantees the gRPC services the panel depends on are // listed in the generated config's api block: HandlerService and StatsService // have always been required for inbound/user management and traffic polling, // and RoutingService enables hot routing reload on templates saved before it // was added to the default template. The stored template itself is not // modified — only the generated runtime config. func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage { if len(api) == 0 { // No api block means the panel's API integration is deliberately // disabled; don't resurrect it behind the user's back. return api } var parsed map[string]any if err := json.Unmarshal(api, &parsed); err != nil { return api } services, _ := parsed["services"].([]any) have := make(map[string]bool, len(services)) for _, svc := range services { if name, ok := svc.(string); ok { have[name] = true } } added := false for _, name := range []string{"HandlerService", "StatsService", "RoutingService"} { if !have[name] { services = append(services, name) added = true } } if !added { return api } parsed["services"] = services out, err := json.Marshal(parsed) if err != nil { return api } return out } // ensureStatsPolicy guarantees every policy level in the generated config has // statsUserOnline enabled, so the core tracks per-email online IPs for the // panel's online view and access-log-free IP limiting. Generated clients carry // no explicit level, so level "0" is created when absent. The flag is panel // infrastructure and is forced on even over an explicit false in the template, // same as the api services above. An entirely missing or unparsable policy // block is left alone; the stored template itself is never modified — only the // generated runtime config. func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage { if len(policy) == 0 { return policy } var parsed map[string]any if err := json.Unmarshal(policy, &parsed); err != nil { return policy } levels, _ := parsed["levels"].(map[string]any) if levels == nil { levels = make(map[string]any) } if _, ok := levels["0"]; !ok { levels["0"] = map[string]any{} } changed := false for _, raw := range levels { level, ok := raw.(map[string]any) if !ok { continue } if enabled, ok := level["statsUserOnline"].(bool); !ok || !enabled { level["statsUserOnline"] = true changed = true } } if !changed { return policy } parsed["levels"] = levels out, err := json.Marshal(parsed) if err != nil { return policy } return out } func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage { if len(logCfg) == 0 { return logCfg } var parsed map[string]any if err := json.Unmarshal(logCfg, &parsed); err != nil { return logCfg } changed := false for _, key := range []string{"access", "error"} { v, ok := parsed[key].(string) if !ok { continue } trimmed := strings.TrimSpace(v) if trimmed == "" || strings.EqualFold(trimmed, "none") { continue } base := path.Base(filepath.ToSlash(trimmed)) if base == "" || base == "." || base == ".." || base == "/" { continue } confined := filepath.Join(config.GetLogFolder(), base) if confined == trimmed { continue } parsed[key] = confined changed = true } if !changed { return logCfg } out, err := json.Marshal(parsed) if err != nil { return logCfg } return out } // stripDisabledRules removes routing rules marked `enabled: false` from the // generated runtime config and strips the panel-only `enabled` key from the // rest, since xray-core has no such field. The internal api rule is always // kept (see isApiRule) so traffic stats can't be toggled off. The stored // template is untouched — only the generated config is filtered. func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage { if len(routerCfg) == 0 { return routerCfg } var parsed map[string]any if err := json.Unmarshal(routerCfg, &parsed); err != nil { return routerCfg } rules, ok := parsed["rules"].([]any) if !ok || len(rules) == 0 { return routerCfg } var activeRules []any changed := false for _, rawRule := range rules { rule, ok := rawRule.(map[string]any) if !ok { activeRules = append(activeRules, rawRule) continue } if enabledRaw, exists := rule["enabled"]; exists { // The internal api rule carries traffic stats and must never be // dropped, even if it was somehow marked disabled. enabled, ok := enabledRaw.(bool) if ok && !enabled && !isApiRule(rule) { changed = true continue } delete(rule, "enabled") changed = true } activeRules = append(activeRules, rule) } if !changed { return routerCfg } parsed["rules"] = activeRules out, err := json.Marshal(parsed) if err != nil { return routerCfg } return out } // GetXrayTraffic fetches the current traffic statistics from the running Xray process. func liftXhttpSessionIDKeys(stream map[string]any) bool { xhttp, ok := stream["xhttpSettings"].(map[string]any) if !ok { return false } changed := false for legacy, renamed := range map[string]string{ "sessionPlacement": "sessionIDPlacement", "sessionKey": "sessionIDKey", } { v, has := xhttp[legacy] if !has { continue } if _, exists := xhttp[renamed]; !exists { xhttp[renamed] = v } delete(xhttp, legacy) changed = true } return changed } // liftOutboundsXhttpSessionIDKeys applies liftXhttpSessionIDKeys to every // outbound's streamSettings in the raw outbounds array. The original bytes are // returned untouched when nothing needs lifting, so an unchanged config never // looks modified to the hot-reload diff. func liftOutboundsXhttpSessionIDKeys(raw json_util.RawMessage) json_util.RawMessage { if len(raw) == 0 { return raw } var outbounds []map[string]any if err := json.Unmarshal(raw, &outbounds); err != nil { return raw } changed := false for _, ob := range outbounds { if stream, ok := ob["streamSettings"].(map[string]any); ok { if liftXhttpSessionIDKeys(stream) { changed = true } } } if !changed { return raw } if rewritten, err := json.Marshal(outbounds); err == nil { return rewritten } return raw }