- 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 遗留)
574 lines
16 KiB
Go
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
|
|
}
|