Files
3x-ui-agent/internal/web/service/xray.go
T
Muse 5efaaef2c2 feat(xray): emit attached clients as accounts for socks/mixed inbounds
Socks-family inbounds authenticate against settings.accounts, so the
clients attached to them never reached the core: the generated config
carried the settings' "clients" mirror (ignored by xray for these
protocols, passwords and all) and only the inbound's own fixed
account. Emit one account per enabled attached client instead —
user = client email, pass = client password — appended after the
inbound's stored accounts, which keep their order and win name
collisions (managed-forwarding resolution reads the fixed account
from the stored settings, untouched). Drop the clients mirror from
the generated settings, and force auth=password when clients exist,
since accounts are inert under noauth.

Accounting needs no new plumbing: xray-core 26.6.27 reports these
accounts under user>>><account user> counters (probe-verified with
two accounts on both mixed and socks), and the agent snapshot ->
report -> AddTraffic chain already keys client traffic by that exact
string, so making the account name the client email aligns booking
end to end. Clientless socks-family inbounds pass through verbatim.
2026-10-21 22:04:49 +00:00

648 lines
19 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)
}
// 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>>><account 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>>><account 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
}