Files
3x-ui-agent/internal/xray/api.go
T

759 lines
23 KiB
Go

// 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"
"errors"
"fmt"
"math"
"net/netip"
"regexp"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protowire"
)
// This build no longer links xray-core. What remains of the core API client:
//
// - StatsService (QueryStats / GetUsersStats) is implemented at the
// protobuf wire level with protowire: those two RPCs carry the traffic
// accounting and online-user data, and their messages are small and
// stable. The agent's traffic reporting and the local traffic job keep
// working exactly as before.
// - The mutation RPCs (HandlerService add/remove/alter, RoutingService
// rule/balancer operations) cannot be reimplemented without the core's
// typed config builders — AddInbound hands the core a fully built
// protobuf handler config, not JSON. They return
// ErrHotApplyUnavailable instead. Every caller already treats an API
// failure as "fall back": hot-apply degrades to a full core restart,
// the balancer view reports not-running, the node bridge probe gives
// up, and the IP-limit temp-disconnect logs and moves on. On a
// control-plane master (the lite build's target) the local core never
// runs, so these paths were unreachable there to begin with.
// ErrHotApplyUnavailable is returned by the core mutation RPCs that this
// build cannot perform without linking xray-core. Callers fall back to
// restarting the core process, which applies the same configuration.
var ErrHotApplyUnavailable = errors.New("xray core hot-apply API unavailable in this build; the change is applied by restarting the core")
// Compiled once at package load: GetTraffic runs on every traffic-stats tick,
// so recompiling these per call is wasted work.
var (
trafficRegex = regexp.MustCompile(`(inbound|outbound)>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
clientTrafficRegex = regexp.MustCompile(`user>>>([^>]+)>>>traffic>>>(downlink|uplink)`)
)
// XrayAPI is a client for the Xray core's gRPC API. Only the stats service
// is functional in this build; see the package comment.
type XrayAPI struct {
grpcClient *grpc.ClientConn
isConnected bool
StatsLastValues map[string]int64
}
// Init connects to the Xray API server.
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)
}
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.isConnected = false
}
// rawBytes is a pre-encoded protobuf message passed through the gRPC stack
// untouched by rawCodec.
type rawBytes []byte
// rawCodec is a gRPC codec that forwards rawBytes as-is. Its name keeps the
// standard "proto" content subtype the core's server expects.
type rawCodec struct{}
func (rawCodec) Marshal(v any) ([]byte, error) {
b, ok := v.(rawBytes)
if !ok {
return nil, fmt.Errorf("xray api: raw codec cannot marshal %T", v)
}
return b, nil
}
func (rawCodec) Unmarshal(data []byte, v any) error {
p, ok := v.(*rawBytes)
if !ok {
return fmt.Errorf("xray api: raw codec cannot unmarshal into %T", v)
}
*p = append((*p)[:0], data...)
return nil
}
func (rawCodec) Name() string { return "proto" }
var _ encoding.Codec = rawCodec{}
const (
statsServiceQueryStats = "/xray.app.stats.command.StatsService/QueryStats"
statsServiceGetUsersStats = "/xray.app.stats.command.StatsService/GetUsersStats"
)
// statEntry is one counter from a QueryStats response
// (xray.app.stats.command.Stat: name = 1, value = 2).
type statEntry struct {
Name string
Value int64
}
// queryStats calls StatsService.QueryStats with an empty pattern and no
// reset — the same request the typed client sent. Both fields are proto3
// zero values, so the encoded request is empty.
func (x *XrayAPI) queryStats(ctx context.Context) ([]statEntry, error) {
var resp rawBytes
if err := x.grpcClient.Invoke(ctx, statsServiceQueryStats, rawBytes{}, &resp, grpc.ForceCodec(rawCodec{})); err != nil {
return nil, err
}
return parseQueryStatsResponse(resp)
}
func parseQueryStatsResponse(b []byte) ([]statEntry, error) {
var out []statEntry
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
if n < 0 {
return nil, protowire.ParseError(n)
}
b = b[n:]
if num == 1 && typ == protowire.BytesType {
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return nil, protowire.ParseError(n)
}
st, err := parseStat(v)
if err != nil {
return nil, err
}
out = append(out, st)
b = b[n:]
continue
}
n = protowire.ConsumeFieldValue(num, typ, b)
if n < 0 {
return nil, protowire.ParseError(n)
}
b = b[n:]
}
return out, nil
}
func parseStat(b []byte) (statEntry, error) {
var st statEntry
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
if n < 0 {
return st, protowire.ParseError(n)
}
b = b[n:]
switch {
case num == 1 && typ == protowire.BytesType:
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return st, protowire.ParseError(n)
}
st.Name = string(v)
b = b[n:]
case num == 2 && typ == protowire.VarintType:
v, n := protowire.ConsumeVarint(b)
if n < 0 {
return st, protowire.ParseError(n)
}
st.Value = int64(v)
b = b[n:]
default:
n := protowire.ConsumeFieldValue(num, typ, b)
if n < 0 {
return st, protowire.ParseError(n)
}
b = b[n:]
}
}
return st, nil
}
// userStatEntry is one user from a GetUsersStats response
// (xray.app.stats.command.UserStat: email = 1, ips = 2;
// OnlineIPEntry: ip = 1, last_seen = 2).
type userStatEntry struct {
Email string
IPs []OnlineIP
}
// getUsersStats calls StatsService.GetUsersStats without traffic and
// without reset — again all proto3 zero values, so the request is empty.
func (x *XrayAPI) getUsersStats(ctx context.Context) ([]userStatEntry, error) {
var resp rawBytes
if err := x.grpcClient.Invoke(ctx, statsServiceGetUsersStats, rawBytes{}, &resp, grpc.ForceCodec(rawCodec{})); err != nil {
return nil, err
}
return parseGetUsersStatsResponse(resp)
}
func parseGetUsersStatsResponse(b []byte) ([]userStatEntry, error) {
var out []userStatEntry
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
if n < 0 {
return nil, protowire.ParseError(n)
}
b = b[n:]
if num == 1 && typ == protowire.BytesType {
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return nil, protowire.ParseError(n)
}
u, err := parseUserStat(v)
if err != nil {
return nil, err
}
out = append(out, u)
b = b[n:]
continue
}
n = protowire.ConsumeFieldValue(num, typ, b)
if n < 0 {
return nil, protowire.ParseError(n)
}
b = b[n:]
}
return out, nil
}
func parseUserStat(b []byte) (userStatEntry, error) {
var u userStatEntry
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
if n < 0 {
return u, protowire.ParseError(n)
}
b = b[n:]
switch {
case num == 1 && typ == protowire.BytesType:
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return u, protowire.ParseError(n)
}
u.Email = string(v)
b = b[n:]
case num == 2 && typ == protowire.BytesType:
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return u, protowire.ParseError(n)
}
ip, err := parseOnlineIPEntry(v)
if err != nil {
return u, err
}
u.IPs = append(u.IPs, ip)
b = b[n:]
default:
n := protowire.ConsumeFieldValue(num, typ, b)
if n < 0 {
return u, protowire.ParseError(n)
}
b = b[n:]
}
}
return u, nil
}
func parseOnlineIPEntry(b []byte) (OnlineIP, error) {
var ip OnlineIP
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
if n < 0 {
return ip, protowire.ParseError(n)
}
b = b[n:]
switch {
case num == 1 && typ == protowire.BytesType:
v, n := protowire.ConsumeBytes(b)
if n < 0 {
return ip, protowire.ParseError(n)
}
ip.IP = string(v)
b = b[n:]
case num == 2 && typ == protowire.VarintType:
v, n := protowire.ConsumeVarint(b)
if n < 0 {
return ip, protowire.ParseError(n)
}
ip.LastSeen = int64(v)
b = b[n:]
default:
n := protowire.ConsumeFieldValue(num, typ, b)
if n < 0 {
return ip, protowire.ParseError(n)
}
b = b[n:]
}
}
return ip, nil
}
// AddInbound is unavailable in this build: it handed the core a protobuf
// handler built by xray-core's config loader. Configuration changes are
// applied by restarting the core (see ErrHotApplyUnavailable).
func (x *XrayAPI) AddInbound(inbound []byte) error {
return ErrHotApplyUnavailable
}
// DelInbound is unavailable in this build; see AddInbound.
func (x *XrayAPI) DelInbound(tag string) error {
return ErrHotApplyUnavailable
}
// AddOutbound is unavailable in this build; see AddInbound.
func (x *XrayAPI) AddOutbound(outbound []byte) error {
return ErrHotApplyUnavailable
}
// DelOutbound is unavailable in this build; see AddInbound.
func (x *XrayAPI) DelOutbound(tag string) error {
return ErrHotApplyUnavailable
}
// ApplyRoutingConfig is unavailable in this build: it handed the core a
// protobuf router config built by xray-core's config loader. Routing
// changes are applied by restarting the core.
func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error {
return ErrHotApplyUnavailable
}
// 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 is unavailable in this build; callers report the
// balancer as not running, exactly as they do for a stopped core.
func (x *XrayAPI) GetBalancerInfo(tag string) (*BalancerInfo, error) {
return nil, ErrHotApplyUnavailable
}
// SetBalancerTarget is unavailable in this build; see GetBalancerInfo.
func (x *XrayAPI) SetBalancerTarget(tag, target string) error {
return ErrHotApplyUnavailable
}
// 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 is unavailable in this build: it needs the RoutingService,
// whose messages embed a fully built routing context. The panel's route
// test already requires a running core and reports this error verbatim.
func (x *XrayAPI) TestRoute(req RouteTestRequest) (*RouteTestResult, error) {
return nil, ErrHotApplyUnavailable
}
// AddUser is unavailable in this build: it handed the core a typed
// per-protocol account built by xray-core. User changes are applied by
// restarting the core (see ErrHotApplyUnavailable).
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
return ErrHotApplyUnavailable
}
// RemoveUser is unavailable in this build; see AddUser.
func (x *XrayAPI) RemoveUser(inboundTag, email string) error {
return ErrHotApplyUnavailable
}
// 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")
}
// IsUserExistsErr reports whether err is xray's response to adding a user whose
// email is already registered on the inbound.
func IsUserExistsErr(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "already exists")
}
// ValidateOutboundConfig checks an outbound JSON object the way the core
// would at startup for the one refusal the panel must surface early:
// since xray-core v26.7.11, an unencrypted VLESS or Trojan outbound whose
// server address is a public IP or domain fails to build. Everything else
// the core's typed loader used to check (per-protocol field shapes) is no
// longer validated in-process; the core still enforces it when it starts
// on the node. The replication below mirrors the core's check exactly,
// including that it only inspects the flat "address" settings field —
// the vnext/servers forms were never covered by it.
//
// The error texts are the core's own, verbatim: the settings layer matches
// on them for its legacy-core version gate.
func ValidateOutboundConfig(outbound []byte) error {
var parsed struct {
Protocol string `json:"protocol"`
Settings json.RawMessage
StreamSettings *struct {
Security string `json:"security"`
} `json:"streamSettings"`
}
if err := json.Unmarshal(outbound, &parsed); err != nil {
return err
}
// A secured stream (tls/reality/…) satisfies the rule outright.
if parsed.StreamSettings != nil && parsed.StreamSettings.Security != "" && parsed.StreamSettings.Security != "none" {
return nil
}
switch strings.ToLower(parsed.Protocol) {
case "vless":
var settings struct {
Encryption string `json:"encryption"`
Address json.RawMessage `json:"address"`
}
if err := json.Unmarshal(parsed.Settings, &settings); err != nil {
return err
}
if settings.Encryption != "" && settings.Encryption != "none" {
return nil
}
if address := flatAddress(settings.Address); address != "" && requiresTransportSecurity(address) {
return errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain")
}
case "trojan":
var settings struct {
Address json.RawMessage `json:"address"`
}
if err := json.Unmarshal(parsed.Settings, &settings); err != nil {
return err
}
if address := flatAddress(settings.Address); address != "" && requiresTransportSecurity(address) {
return errors.New("trojan without TLS is prohibited unless the server address is a private IP or domain")
}
}
return nil
}
// flatAddress extracts the flat outbound "address" field, which the core's
// prohibition check reads. The field is a plain string in config JSON; any
// other shape means the outbound uses the vnext/servers form the core's
// check does not inspect.
func flatAddress(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var address string
if err := json.Unmarshal(raw, &address); err != nil {
return ""
}
return address
}
// privateIPPrefixes is xray-core's private-IP matcher list, verbatim
// (common/geodata consts): documentation ranges, CGNAT, multicast and
// reserved space count as private for the prohibition check.
var privateIPPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/3"),
netip.MustParsePrefix("::/127"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
}
// privateDomainSuffixes is xray-core's private-domain matcher list,
// verbatim: each entry matches the domain itself and any subdomain.
// Dotless domains are private too (the core matches them by regexp).
var privateDomainSuffixes = []string{
"lan",
"localdomain",
"example",
"invalid",
"localhost",
"test",
"local",
"home.arpa",
"internal",
}
var dotlessDomainRegex = regexp.MustCompile(`^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`)
// requiresTransportSecurity mirrors the core's check of the same name: an
// IP must fall outside every private range; a domain must fall outside
// every private suffix (or be dotless).
func requiresTransportSecurity(address string) bool {
if ip, err := netip.ParseAddr(address); err == nil {
for _, prefix := range privateIPPrefixes {
if prefix.Contains(ip) {
return false
}
}
return true
}
domain := strings.TrimSuffix(strings.ToLower(address), ".")
if dotlessDomainRegex.MatchString(domain) {
return false
}
for _, suffix := range privateDomainSuffixes {
if domain == suffix || strings.HasSuffix(domain, "."+suffix) {
return false
}
}
return true
}
// GetTraffic queries traffic statistics from the Xray core and reports what
// accrued since the previous call; the counters themselves are never reset.
// The first call of a process only records baselines, since it may be reading
// counters that already hold traffic the panel cannot attribute. After that a
// name the panel has not seen — xray creates a counter on a user's first use —
// and a counter that moved backwards because the core restarted both count
// from zero, so no client's traffic is dropped for a whole polling interval.
func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
if x.grpcClient == nil {
return nil, nil, common.NewError("xray api is not initialized")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
stats, err := x.queryStats(ctx)
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)
if x.StatsLastValues == nil {
x.StatsLastValues = make(map[string]int64)
}
baselinePass := len(x.StatsLastValues) == 0
for _, stat := range stats {
lastValue, ok := x.StatsLastValues[stat.Name]
x.StatsLastValues[stat.Name] = stat.Value
if baselinePass {
continue
}
if !ok || stat.Value < lastValue {
lastValue = 0
}
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)
}
}
// Drop delta baselines for stats that no longer exist (deleted inbounds or
// clients), which otherwise linger until the next Xray restart. Only rebuild
// when the map has drifted past 2x the live set, so the steady-state hot path
// stays allocation-free.
if n := len(stats); n > 0 && len(x.StatsLastValues) > 2*n {
pruned := make(map[string]int64, n)
for _, stat := range stats {
pruned[stat.Name] = x.StatsLastValues[stat.Name]
}
x.StatsLastValues = pruned
}
return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
}
// OnlineIP is one source address of a live connection, with the unix time (seconds)
// the core last dispatched a link from it.
type OnlineIP struct {
IP string `json:"ip"`
LastSeen int64 `json:"lastSeen"`
}
// OnlineUser is a client email with at least one live connection and the source
// IPs of those connections, as tracked by Xray's statsUserOnline policy.
type OnlineUser struct {
Email string `json:"email"`
IPs []OnlineIP `json:"ips"`
}
// GetOnlineUsers returns every user with at least one live connection plus their
// source IPs, via StatsService.GetUsersStats (one RPC covers all users). Requires
// statsUserOnline enabled in the policy levels; older cores return Unimplemented.
func (x *XrayAPI) GetOnlineUsers() ([]OnlineUser, error) {
if x.grpcClient == nil {
return nil, common.NewError("xray api is not initialized")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
stats, err := x.getUsersStats(ctx)
if err != nil {
return nil, err
}
users := make([]OnlineUser, 0, len(stats))
for _, u := range stats {
if u.Email == "" {
continue
}
ips := make([]OnlineIP, 0, len(u.IPs))
for _, entry := range u.IPs {
if entry.IP == "" {
continue
}
ips = append(ips, entry)
}
users = append(users, OnlineUser{Email: u.Email, IPs: ips})
}
return users, nil
}
// IsUnimplementedErr reports whether err is the running core saying it lacks an
// RPC (an older Xray binary without the online-stats API).
func IsUnimplementedErr(err error) bool {
return status.Code(err) == codes.Unimplemented
}
// 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
}