| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626 |
- // Package xray provides integration with the Xray proxy core.
- // It includes API client functionality, configuration management, traffic monitoring,
- // and process control for Xray instances.
- package xray
- import (
- "context"
- "encoding/json"
- "fmt"
- "math"
- "net"
- "os"
- "path/filepath"
- "regexp"
- "strings"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/config"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- "github.com/mhsanaei/3x-ui/v3/internal/util/common"
- "github.com/xtls/xray-core/app/proxyman/command"
- routerService "github.com/xtls/xray-core/app/router/command"
- statsService "github.com/xtls/xray-core/app/stats/command"
- xnet "github.com/xtls/xray-core/common/net"
- "github.com/xtls/xray-core/common/protocol"
- "github.com/xtls/xray-core/common/serial"
- "github.com/xtls/xray-core/infra/conf"
- hysteriaAccount "github.com/xtls/xray-core/proxy/hysteria/account"
- "github.com/xtls/xray-core/proxy/shadowsocks"
- "github.com/xtls/xray-core/proxy/shadowsocks_2022"
- "github.com/xtls/xray-core/proxy/trojan"
- "github.com/xtls/xray-core/proxy/vless"
- "github.com/xtls/xray-core/proxy/vmess"
- "google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
- )
- // XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics.
- type XrayAPI struct {
- HandlerServiceClient *command.HandlerServiceClient
- StatsServiceClient *statsService.StatsServiceClient
- RoutingServiceClient *routerService.RoutingServiceClient
- grpcClient *grpc.ClientConn
- isConnected bool
- StatsLastValues map[string]int64
- }
- func getRequiredUserString(user map[string]any, key string) (string, error) {
- value, ok := user[key]
- if !ok || value == nil {
- return "", fmt.Errorf("missing required user field %q", key)
- }
- strValue, ok := value.(string)
- if !ok {
- return "", fmt.Errorf("invalid type for user field %q: %T", key, value)
- }
- return strValue, nil
- }
- func getOptionalUserString(user map[string]any, key string) (string, error) {
- value, ok := user[key]
- if !ok || value == nil {
- return "", nil
- }
- strValue, ok := value.(string)
- if !ok {
- return "", fmt.Errorf("invalid type for user field %q: %T", key, value)
- }
- return strValue, nil
- }
- // Init connects to the Xray API server and initializes handler and stats service clients.
- func (x *XrayAPI) Init(apiPort int) error {
- if apiPort <= 0 || apiPort > math.MaxUint16 {
- return fmt.Errorf("invalid Xray API port: %d", apiPort)
- }
- addr := fmt.Sprintf("127.0.0.1:%d", apiPort)
- conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
- if err != nil {
- return fmt.Errorf("failed to connect to Xray API: %w", err)
- }
- x.grpcClient = conn
- x.isConnected = true
- if x.StatsLastValues == nil {
- x.StatsLastValues = make(map[string]int64)
- }
- hsClient := command.NewHandlerServiceClient(conn)
- ssClient := statsService.NewStatsServiceClient(conn)
- rsClient := routerService.NewRoutingServiceClient(conn)
- x.HandlerServiceClient = &hsClient
- x.StatsServiceClient = &ssClient
- x.RoutingServiceClient = &rsClient
- return nil
- }
- // Close closes the gRPC connection and resets the XrayAPI client state.
- func (x *XrayAPI) Close() {
- if x.grpcClient != nil {
- x.grpcClient.Close()
- }
- x.HandlerServiceClient = nil
- x.StatsServiceClient = nil
- x.RoutingServiceClient = nil
- x.isConnected = false
- }
- // AddInbound adds a new inbound configuration to the Xray core via gRPC.
- func (x *XrayAPI) AddInbound(inbound []byte) error {
- client := *x.HandlerServiceClient
- conf := new(conf.InboundDetourConfig)
- err := json.Unmarshal(inbound, conf)
- if err != nil {
- logger.Debug("Failed to unmarshal inbound:", err)
- return err
- }
- config, err := conf.Build()
- if err != nil {
- logger.Debug("Failed to build inbound Detur:", err)
- return err
- }
- inboundConfig := command.AddInboundRequest{Inbound: config}
- _, err = client.AddInbound(context.Background(), &inboundConfig)
- return err
- }
- // DelInbound removes an inbound configuration from the Xray core by tag.
- func (x *XrayAPI) DelInbound(tag string) error {
- client := *x.HandlerServiceClient
- _, err := client.RemoveInbound(context.Background(), &command.RemoveInboundRequest{
- Tag: tag,
- })
- return err
- }
- // AddOutbound adds a new outbound configuration to the Xray core via gRPC.
- func (x *XrayAPI) AddOutbound(outbound []byte) error {
- if x.HandlerServiceClient == nil {
- return common.NewError("xray HandlerServiceClient is not initialized")
- }
- client := *x.HandlerServiceClient
- conf := new(conf.OutboundDetourConfig)
- if err := json.Unmarshal(outbound, conf); err != nil {
- logger.Debug("Failed to unmarshal outbound:", err)
- return err
- }
- config, err := conf.Build()
- if err != nil {
- logger.Debug("Failed to build outbound detour:", err)
- return err
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- _, err = client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: config})
- return err
- }
- // DelOutbound removes an outbound configuration from the Xray core by tag.
- func (x *XrayAPI) DelOutbound(tag string) error {
- if x.HandlerServiceClient == nil {
- return common.NewError("xray HandlerServiceClient is not initialized")
- }
- client := *x.HandlerServiceClient
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- _, err := client.RemoveOutbound(ctx, &command.RemoveOutboundRequest{Tag: tag})
- return err
- }
- // ApplyRoutingConfig replaces the routing rules and balancers of the running
- // Xray core with the given routing section (the JSON value of the top-level
- // "routing" key) via the RoutingService gRPC API. Note that this cannot change
- // routing.domainStrategy/domainMatcher — those are fixed at process start.
- func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error {
- if x.RoutingServiceClient == nil {
- return common.NewError("xray RoutingServiceClient is not initialized")
- }
- // Rules referencing geoip:/geosite: need the dat files; point xray-core's
- // in-process loader at the panel's bin folder where they live.
- ensureXrayAssetLocation()
- routerConf := new(conf.RouterConfig)
- if err := json.Unmarshal(routing, routerConf); err != nil {
- logger.Debug("Failed to unmarshal routing config:", err)
- return err
- }
- config, err := routerConf.Build()
- if err != nil {
- logger.Debug("Failed to build routing config:", err)
- return err
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _, err = (*x.RoutingServiceClient).AddRule(ctx, &routerService.AddRuleRequest{
- ShouldAppend: false,
- Config: serial.ToTypedMessage(config),
- })
- return err
- }
- // BalancerInfo is the live state of one balancer inside the running core.
- type BalancerInfo struct {
- Tag string `json:"tag"`
- // Override is the outbound tag an admin forced via the API; empty when
- // the strategy is in control.
- Override string `json:"override"`
- // Selected are the outbound tags the strategy currently prefers, best
- // first (xray's "principle target" list).
- Selected []string `json:"selected"`
- }
- // GetBalancerInfo queries the running core for a balancer's current override
- // and the targets its strategy would pick right now.
- func (x *XrayAPI) GetBalancerInfo(tag string) (*BalancerInfo, error) {
- if x.RoutingServiceClient == nil {
- return nil, common.NewError("xray RoutingServiceClient is not initialized")
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- resp, err := (*x.RoutingServiceClient).GetBalancerInfo(ctx, &routerService.GetBalancerInfoRequest{Tag: tag})
- if err != nil {
- return nil, err
- }
- info := &BalancerInfo{Tag: tag}
- if balancer := resp.GetBalancer(); balancer != nil {
- if balancer.Override != nil {
- info.Override = balancer.Override.Target
- }
- if balancer.PrincipleTarget != nil {
- info.Selected = balancer.PrincipleTarget.Tag
- }
- }
- return info, nil
- }
- // SetBalancerTarget forces a balancer to always pick the given outbound tag.
- // An empty target clears the override and hands control back to the strategy.
- func (x *XrayAPI) SetBalancerTarget(tag, target string) error {
- if x.RoutingServiceClient == nil {
- return common.NewError("xray RoutingServiceClient is not initialized")
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- _, err := (*x.RoutingServiceClient).OverrideBalancerTarget(ctx, &routerService.OverrideBalancerTargetRequest{
- BalancerTag: tag,
- Target: target,
- })
- return err
- }
- // RouteTestRequest describes a synthetic connection to ask the running core
- // which outbound its router would pick for it.
- type RouteTestRequest struct {
- InboundTag string // optional: simulate arrival on this inbound
- Domain string // target domain (sniffed/SOCKS-style destination)
- IP string // target IP, used when Domain is empty or alongside it
- Port int
- Network string // "tcp" (default) or "udp"
- Protocol string // optional sniffed protocol: http, tls, bittorrent, ...
- Email string // optional user attribution for user-based rules
- }
- // RouteTestResult is the routing decision the core reported.
- type RouteTestResult struct {
- // Matched is false when no routing rule matched — traffic would use the
- // default (first) outbound and OutboundTag is empty.
- Matched bool `json:"matched"`
- OutboundTag string `json:"outboundTag"`
- // GroupTags lists the balancer chain the decision went through, when any.
- GroupTags []string `json:"groupTags,omitempty"`
- }
- // TestRoute asks the running core's router which outbound it would pick for
- // the described connection, without sending any traffic.
- func (x *XrayAPI) TestRoute(req RouteTestRequest) (*RouteTestResult, error) {
- if x.RoutingServiceClient == nil {
- return nil, common.NewError("xray RoutingServiceClient is not initialized")
- }
- network := xnet.Network_TCP
- if strings.EqualFold(req.Network, "udp") {
- network = xnet.Network_UDP
- }
- rc := &routerService.RoutingContext{
- InboundTag: req.InboundTag,
- Network: network,
- TargetDomain: req.Domain,
- TargetPort: uint32(req.Port),
- Protocol: req.Protocol,
- User: req.Email,
- }
- if req.IP != "" {
- parsed := net.ParseIP(req.IP)
- if parsed == nil {
- return nil, common.NewErrorf("invalid IP address: %s", req.IP)
- }
- if v4 := parsed.To4(); v4 != nil {
- rc.TargetIPs = [][]byte{v4}
- } else {
- rc.TargetIPs = [][]byte{parsed.To16()}
- }
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- resp, err := (*x.RoutingServiceClient).TestRoute(ctx, &routerService.TestRouteRequest{
- RoutingContext: rc,
- PublishResult: false,
- })
- if err != nil {
- // The router reports "no rule matched" as an error; for the caller
- // that simply means the default outbound takes the traffic.
- if strings.Contains(strings.ToLower(err.Error()), "not enough information") {
- return &RouteTestResult{Matched: false}, nil
- }
- return nil, err
- }
- return &RouteTestResult{
- Matched: true,
- OutboundTag: resp.GetOutboundTag(),
- GroupTags: resp.GetOutboundGroupTags(),
- }, nil
- }
- // IsMissingHandlerErr reports whether err is xray's response to removing a
- // handler (inbound/outbound) that does not exist — e.g. it was already
- // removed through the runtime API while the panel's config snapshot was
- // stale. Safe to treat as success for removal operations.
- func IsMissingHandlerErr(err error) bool {
- if err == nil {
- return false
- }
- msg := strings.ToLower(err.Error())
- return strings.Contains(msg, "not found") ||
- strings.Contains(msg, "not enough information")
- }
- // IsExistingTagErr reports whether err is xray's response to adding a handler
- // whose tag is already taken by a running handler.
- func IsExistingTagErr(err error) bool {
- if err == nil {
- return false
- }
- return strings.Contains(strings.ToLower(err.Error()), "existing tag")
- }
- // ensureXrayAssetLocation makes geoip.dat/geosite.dat resolvable when xray-core
- // config builders run inside the panel process. The xray binary resolves assets
- // relative to its own executable, but the panel binary lives one level above
- // the bin folder, so an explicit location is required.
- func ensureXrayAssetLocation() {
- if os.Getenv("XRAY_LOCATION_ASSET") != "" || os.Getenv("xray.location.asset") != "" {
- return
- }
- if abs, err := filepath.Abs(config.GetBinFolderPath()); err == nil {
- os.Setenv("XRAY_LOCATION_ASSET", abs)
- }
- }
- // AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
- func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
- userEmail, err := getRequiredUserString(user, "email")
- if err != nil {
- return err
- }
- var account *serial.TypedMessage
- switch Protocol {
- case "vmess":
- userID, err := getRequiredUserString(user, "id")
- if err != nil {
- return err
- }
- account = serial.ToTypedMessage(&vmess.Account{
- Id: userID,
- })
- case "vless":
- userID, err := getRequiredUserString(user, "id")
- if err != nil {
- return err
- }
- userFlow, err := getOptionalUserString(user, "flow")
- if err != nil {
- return err
- }
- vlessAccount := &vless.Account{
- Id: userID,
- Flow: userFlow,
- }
- // Add testseed if provided
- if testseedVal, ok := user["testseed"]; ok {
- if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 {
- testseed := make([]uint32, len(testseedArr))
- for i, v := range testseedArr {
- if num, ok := v.(float64); ok {
- testseed[i] = uint32(num)
- }
- }
- vlessAccount.Testseed = testseed
- } else if testseedArr, ok := testseedVal.([]uint32); ok && len(testseedArr) >= 4 {
- vlessAccount.Testseed = testseedArr
- }
- }
- // Add testpre if provided (for outbound, but can be in user for compatibility)
- if testpreVal, ok := user["testpre"]; ok {
- if testpre, ok := testpreVal.(float64); ok && testpre > 0 {
- vlessAccount.Testpre = uint32(testpre)
- } else if testpre, ok := testpreVal.(uint32); ok && testpre > 0 {
- vlessAccount.Testpre = testpre
- }
- }
- account = serial.ToTypedMessage(vlessAccount)
- case "trojan":
- password, err := getRequiredUserString(user, "password")
- if err != nil {
- return err
- }
- account = serial.ToTypedMessage(&trojan.Account{
- Password: password,
- })
- case "shadowsocks":
- cipher, err := getOptionalUserString(user, "cipher")
- if err != nil {
- return err
- }
- password, err := getRequiredUserString(user, "password")
- if err != nil {
- return err
- }
- var ssCipherType shadowsocks.CipherType
- switch cipher {
- case "aes-256-gcm":
- ssCipherType = shadowsocks.CipherType_AES_256_GCM
- case "chacha20-poly1305", "chacha20-ietf-poly1305":
- ssCipherType = shadowsocks.CipherType_CHACHA20_POLY1305
- case "xchacha20-poly1305", "xchacha20-ietf-poly1305":
- ssCipherType = shadowsocks.CipherType_XCHACHA20_POLY1305
- default:
- ssCipherType = shadowsocks.CipherType_NONE
- }
- if ssCipherType != shadowsocks.CipherType_NONE {
- account = serial.ToTypedMessage(&shadowsocks.Account{
- Password: password,
- CipherType: ssCipherType,
- })
- } else {
- account = serial.ToTypedMessage(&shadowsocks_2022.ServerConfig{
- Key: password,
- Email: userEmail,
- })
- }
- case "hysteria":
- auth, err := getRequiredUserString(user, "auth")
- if err != nil {
- return err
- }
- account = serial.ToTypedMessage(&hysteriaAccount.Account{
- Auth: auth,
- })
- default:
- return nil
- }
- client := *x.HandlerServiceClient
- _, err = client.AlterInbound(context.Background(), &command.AlterInboundRequest{
- Tag: inboundTag,
- Operation: serial.ToTypedMessage(&command.AddUserOperation{
- User: &protocol.User{
- Email: userEmail,
- Account: account,
- },
- }),
- })
- return err
- }
- // RemoveUser removes a user from an inbound in the Xray core by email.
- func (x *XrayAPI) RemoveUser(inboundTag, email string) error {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- op := &command.RemoveUserOperation{Email: email}
- req := &command.AlterInboundRequest{
- Tag: inboundTag,
- Operation: serial.ToTypedMessage(op),
- }
- _, err := (*x.HandlerServiceClient).AlterInbound(ctx, req)
- if err != nil {
- return fmt.Errorf("failed to remove user: %w", err)
- }
- return nil
- }
- // GetTraffic queries traffic statistics from the Xray core, optionally resetting counters.
- func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
- if x.grpcClient == nil {
- return nil, nil, common.NewError("xray api is not initialized")
- }
- trafficRegex := regexp.MustCompile(`(inbound|outbound)>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
- clientTrafficRegex := regexp.MustCompile(`user>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
- ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
- defer cancel()
- if x.StatsServiceClient == nil {
- return nil, nil, common.NewError("xray StatusServiceClient is not initialized")
- }
- resp, err := (*x.StatsServiceClient).QueryStats(ctx, &statsService.QueryStatsRequest{Reset_: false})
- if err != nil {
- logger.Debug("Failed to query Xray stats:", err)
- return nil, nil, err
- }
- tagTrafficMap := make(map[string]*Traffic)
- emailTrafficMap := make(map[string]*ClientTraffic)
- for _, stat := range resp.GetStat() {
- lastValue, ok := x.StatsLastValues[stat.Name]
- x.StatsLastValues[stat.Name] = stat.Value
- if !ok || stat.Value < lastValue {
- // skip first time of seen stat
- continue
- }
- value := stat.Value - lastValue
- if matches := trafficRegex.FindStringSubmatch(stat.Name); len(matches) == 4 {
- processTraffic(matches, value, tagTrafficMap)
- } else if matches := clientTrafficRegex.FindStringSubmatch(stat.Name); len(matches) == 3 {
- processClientTraffic(matches, value, emailTrafficMap)
- }
- }
- return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
- }
- // processTraffic aggregates a traffic stat into trafficMap using regex matches and value.
- func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) {
- isInbound := matches[1] == "inbound"
- tag := matches[2]
- isDown := matches[3] == "downlink"
- if tag == "api" {
- return
- }
- traffic, ok := trafficMap[tag]
- if !ok {
- traffic = &Traffic{
- IsInbound: isInbound,
- IsOutbound: !isInbound,
- Tag: tag,
- }
- trafficMap[tag] = traffic
- }
- if isDown {
- traffic.Down = value
- } else {
- traffic.Up = value
- }
- }
- // processClientTraffic updates clientTrafficMap with upload/download values for a client email.
- func processClientTraffic(matches []string, value int64, clientTrafficMap map[string]*ClientTraffic) {
- email := matches[1]
- isDown := matches[2] == "downlink"
- traffic, ok := clientTrafficMap[email]
- if !ok {
- traffic = &ClientTraffic{Email: email}
- clientTrafficMap[email] = traffic
- }
- if isDown {
- traffic.Down = value
- } else {
- traffic.Up = value
- }
- }
- // mapToSlice converts a map of pointers to a slice of pointers.
- func mapToSlice[T any](m map[string]*T) []*T {
- result := make([]*T, 0, len(m))
- for _, v := range m {
- result = append(result, v)
- }
- return result
- }
|