Files
3x-ui-agent/internal/web/service/xray.go
T
Muse 1035d00fe4 精简版P9(4b): 删除本地核心整条路径(进程管理/buildXrayConfig本地分支/local-only注入/本地核心接口与任务)
- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
2026-10-21 18:43:55 +00:00

574 lines
16 KiB
Go

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)
}
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
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.WireGuard:
wgPeers = append(wgPeers, model.WireguardPeerFromClient(c))
continue
}
finalClients = append(finalClients, entry)
}
var mutated bool
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
}