mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
cfa8350d10
* fix(clients): keep a vless reverse client's handler across a re-add RemoveUser also drops the client's reverse outbound handler, and the account every live remove/re-add path rebuilt carried no reverse at all: buildUserAccount read id/flow/testseed/testpre and nothing else. Editing, bulk re-enabling, quota renewal and adding a client to an existing inbound therefore left a reverse client able to connect but not to open its tunnel until Xray restarted, with nothing logged. A traffic reset is the route operators hit most, since a depleted client is removed and re-added on every renewal. buildUserAccount now carries the tag (it accepts either the settings JSON object or a typed client value), and the five account maps those paths build include the client's reverse. Core chain, read from the pinned xray-core: AddUserOperation -> User.ToMemoryUser -> vless.Account.AsAccount copies Reverse (proxy/vless/account.go:24), and GetReverse rebuilds the handler from the stored account's tag (proxy/vless/inbound/inbound.go:193-205). Each path has a test that fails without its fix; the account-level test fails on both input shapes. * refactor(clients): drop an account map helper nothing calls Local.AddClient and Local.UpdateUser are only reachable through runtime.Runtime, and all four call sites of those two methods sit in a node branch, where the runtime is a *Remote -- Remote.AddUser ignores the map and pushes the inbound snapshot instead. So the extraction and its test covered a path no deployment takes, the reverse key it added could never reach a core, and the previous commit's claim that the node-push paths go through it was wrong. The four account maps that do reach buildUserAccount are untouched. Reported by the PR review.
924 lines
29 KiB
Go
924 lines
29 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"
|
|
"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"
|
|
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
|
|
|
"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"
|
|
wireguard "github.com/xtls/xray-core/proxy/wireguard"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// handlerRPCTimeout bounds per-call gRPC handler operations (add/remove inbound,
|
|
// alter user) so a hung core connection cannot block the caller indefinitely —
|
|
// for example while the process restart lock is held.
|
|
const handlerRPCTimeout = 10 * time.Second
|
|
|
|
// AddInbound adds a new inbound configuration to the Xray core via gRPC.
|
|
func (x *XrayAPI) AddInbound(inbound []byte) error {
|
|
if x.HandlerServiceClient == nil {
|
|
return common.NewError("xray HandlerServiceClient is not initialized")
|
|
}
|
|
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}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
|
|
defer cancel()
|
|
_, err = client.AddInbound(ctx, &inboundConfig)
|
|
|
|
return err
|
|
}
|
|
|
|
// DelInbound removes an inbound configuration from the Xray core by tag.
|
|
func (x *XrayAPI) DelInbound(tag string) error {
|
|
if x.HandlerServiceClient == nil {
|
|
return common.NewError("xray HandlerServiceClient is not initialized")
|
|
}
|
|
client := *x.HandlerServiceClient
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
|
|
defer cancel()
|
|
_, err := client.RemoveInbound(ctx, &command.RemoveInboundRequest{
|
|
Tag: tag,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// ValidateOutboundConfig builds an outbound JSON object through the vendored
|
|
// xray-core config loader, surfacing the exact error the core would raise at
|
|
// startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds
|
|
// whose server address is a public IP or domain.
|
|
func ValidateOutboundConfig(outbound []byte) error {
|
|
ensureXrayAssetLocation()
|
|
|
|
detour := new(conf.OutboundDetourConfig)
|
|
if err := json.Unmarshal(outbound, detour); err != nil {
|
|
return err
|
|
}
|
|
_, err := detour.Build()
|
|
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
|
|
|
|
ensureXrayAssetLocation()
|
|
|
|
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")
|
|
}
|
|
|
|
if req.Port < 0 || req.Port > math.MaxUint16 {
|
|
return nil, common.NewErrorf("invalid port: %d", req.Port)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// collectStringSlice normalizes a JSON-decoded value into a slice of non-empty
|
|
// strings, accepting both []string (typed maps) and []any (json.Unmarshal output).
|
|
func collectStringSlice(value any) []string {
|
|
switch v := value.(type) {
|
|
case []string:
|
|
out := make([]string, 0, len(v))
|
|
for _, s := range v {
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, e := range v {
|
|
if s, ok := e.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// legacyShadowsocksAccountType is the type URL serial.ToTypedMessage stamps on
|
|
// a pre-2022 shadowsocks account, which identifies the one inbound whose user
|
|
// list tolerates duplicate emails.
|
|
const legacyShadowsocksAccountType = "xray.proxy.shadowsocks.Account"
|
|
|
|
// shadowsocks2022Ciphers are the methods that select xray's shadowsocks-2022
|
|
// inbound (sing's shadowaead_2022 list). They take a different account type
|
|
// than the legacy AEAD ciphers, and the running inbound casts the account it
|
|
// receives without checking, so a wrong guess takes the whole core down.
|
|
var shadowsocks2022Ciphers = map[string]struct{}{
|
|
"2022-blake3-aes-128-gcm": {},
|
|
"2022-blake3-aes-256-gcm": {},
|
|
"2022-blake3-chacha20-poly1305": {},
|
|
}
|
|
|
|
// shadowsocksCipherName resolves the cipher a shadowsocks user's account must
|
|
// be built for. Panel-built user maps carry it under "cipher"; client objects
|
|
// taken verbatim from an inbound's settings carry the inbound's method under
|
|
// "method" instead (HealShadowsocksClientMethods writes it onto every
|
|
// legacy-cipher client).
|
|
func shadowsocksCipherName(user map[string]any) (string, error) {
|
|
cipher, err := getOptionalUserString(user, "cipher")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if cipher != "" {
|
|
return cipher, nil
|
|
}
|
|
return getOptionalUserString(user, "method")
|
|
}
|
|
|
|
// reverseTag reads a vless reverse proxy tag from either shape a caller can
|
|
// carry: the settings JSON object, or a typed client value marshalling alike.
|
|
func reverseTag(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var parsed struct {
|
|
Tag string `json:"tag"`
|
|
}
|
|
if json.Unmarshal(raw, &parsed) != nil {
|
|
return ""
|
|
}
|
|
return parsed.Tag
|
|
}
|
|
|
|
// shadowsocksCipherType mirrors xray-core's infra/conf cipherFromString,
|
|
// aliases and case-insensitivity included, so the account the panel builds for
|
|
// a live user matches the one the core built for that inbound from its config.
|
|
func shadowsocksCipherType(cipher string) shadowsocks.CipherType {
|
|
switch strings.ToLower(cipher) {
|
|
case "aes-128-gcm", "aead_aes_128_gcm":
|
|
return shadowsocks.CipherType_AES_128_GCM
|
|
case "aes-256-gcm", "aead_aes_256_gcm":
|
|
return shadowsocks.CipherType_AES_256_GCM
|
|
case "chacha20-poly1305", "aead_chacha20_poly1305", "chacha20-ietf-poly1305":
|
|
return shadowsocks.CipherType_CHACHA20_POLY1305
|
|
case "xchacha20-poly1305", "aead_xchacha20_poly1305", "xchacha20-ietf-poly1305":
|
|
return shadowsocks.CipherType_XCHACHA20_POLY1305
|
|
default:
|
|
return shadowsocks.CipherType_UNKNOWN
|
|
}
|
|
}
|
|
|
|
// isShadowsocks2022Cipher reports whether the method selects the
|
|
// shadowsocks-2022 inbound rather than the legacy AEAD one.
|
|
func isShadowsocks2022Cipher(cipher string) bool {
|
|
_, ok := shadowsocks2022Ciphers[strings.ToLower(cipher)]
|
|
return ok
|
|
}
|
|
|
|
// buildUserAccount constructs the typed xray account for a user of the given
|
|
// protocol. It returns (nil, nil) for protocols that cannot be altered live so
|
|
// callers skip the AlterInbound call. WireGuard keys must be converted to the
|
|
// hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString),
|
|
// unlike the file-config path which accepts base64 and converts internally.
|
|
// Shadowsocks is resolved strictly from the inbound's cipher: the legacy and
|
|
// 2022 inbounds take different account types and cast whatever they receive
|
|
// without checking, so an unrecognized cipher is an error rather than a guess
|
|
// that would panic the core and kill every connection on the server.
|
|
func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) {
|
|
switch protocolName {
|
|
case "vmess":
|
|
userID, err := getRequiredUserString(user, "id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return serial.ToTypedMessage(&vmess.Account{
|
|
Id: userID,
|
|
}), nil
|
|
case "vless":
|
|
userID, err := getRequiredUserString(user, "id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
userFlow, err := getOptionalUserString(user, "flow")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vlessAccount := &vless.Account{
|
|
Id: userID,
|
|
Flow: userFlow,
|
|
}
|
|
// RemoveUser also drops the account's reverse outbound handler, and
|
|
// GetReverse only rebuilds it from the tag a re-added account carries.
|
|
if tag := reverseTag(user["reverse"]); tag != "" {
|
|
vlessAccount.Reverse = &vless.Reverse{Tag: tag}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
return serial.ToTypedMessage(vlessAccount), nil
|
|
case "trojan":
|
|
password, err := getRequiredUserString(user, "password")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return serial.ToTypedMessage(&trojan.Account{
|
|
Password: password,
|
|
}), nil
|
|
case "shadowsocks":
|
|
cipher, err := shadowsocksCipherName(user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
password, err := getRequiredUserString(user, "password")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if isShadowsocks2022Cipher(cipher) {
|
|
return serial.ToTypedMessage(&shadowsocks_2022.Account{
|
|
Key: password,
|
|
}), nil
|
|
}
|
|
|
|
ssCipherType := shadowsocksCipherType(cipher)
|
|
if ssCipherType == shadowsocks.CipherType_UNKNOWN {
|
|
return nil, common.NewErrorf("shadowsocks: unknown cipher %q, cannot build an account for the running inbound", cipher)
|
|
}
|
|
return serial.ToTypedMessage(&shadowsocks.Account{
|
|
Password: password,
|
|
CipherType: ssCipherType,
|
|
}), nil
|
|
case "hysteria":
|
|
auth, err := getRequiredUserString(user, "auth")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return serial.ToTypedMessage(&hysteriaAccount.Account{
|
|
Auth: auth,
|
|
}), nil
|
|
case "wireguard":
|
|
pubB64, err := getRequiredUserString(user, "publicKey")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pubHex, err := wgutil.KeyToHex(pubB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wireguard publicKey: %w", err)
|
|
}
|
|
|
|
pskB64, err := getOptionalUserString(user, "preSharedKey")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pskHex, err := wgutil.KeyToHex(pskB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wireguard preSharedKey: %w", err)
|
|
}
|
|
|
|
allowed := collectStringSlice(user["allowedIPs"])
|
|
if len(allowed) == 0 {
|
|
return nil, common.NewError("wireguard: allowedIPs required")
|
|
}
|
|
|
|
keepAlive, err := getOptionalUserString(user, "keepAlive")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return serial.ToTypedMessage(&wireguard.PeerConfig{
|
|
PublicKey: pubHex,
|
|
PreSharedKey: pskHex,
|
|
AllowedIps: allowed,
|
|
KeepAlive: keepAlive,
|
|
}), nil
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// AddUser adds a user to an inbound in the Xray core using the specified
|
|
// protocol and user data. On a legacy shadowsocks inbound the add first drops
|
|
// any existing holder of the email: that is the one inbound whose validator
|
|
// does not reject a duplicate email, and a later removal would then drop just
|
|
// one of the two registrations, leaving a disabled client able to connect.
|
|
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
|
|
userEmail, err := getRequiredUserString(user, "email")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
account, err := buildUserAccount(Protocol, user)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if account == nil {
|
|
return nil
|
|
}
|
|
|
|
if x.HandlerServiceClient == nil {
|
|
return common.NewError("xray HandlerServiceClient is not initialized")
|
|
}
|
|
client := *x.HandlerServiceClient
|
|
|
|
if account.Type == legacyShadowsocksAccountType {
|
|
_ = x.RemoveUser(inboundTag, userEmail)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
|
|
defer cancel()
|
|
_, err = client.AlterInbound(ctx, &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 {
|
|
if x.HandlerServiceClient == nil {
|
|
return common.NewError("xray HandlerServiceClient is not initialized")
|
|
}
|
|
client := *x.HandlerServiceClient
|
|
|
|
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 := client.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 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()
|
|
|
|
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)
|
|
|
|
baselinePass := len(x.StatsLastValues) == 0
|
|
|
|
for _, stat := range resp.GetStat() {
|
|
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(resp.GetStat()); n > 0 && len(x.StatsLastValues) > 2*n {
|
|
pruned := make(map[string]int64, n)
|
|
for _, stat := range resp.GetStat() {
|
|
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")
|
|
}
|
|
if x.StatsServiceClient == nil {
|
|
return nil, common.NewError("xray StatsServiceClient is not initialized")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
|
defer cancel()
|
|
|
|
resp, err := (*x.StatsServiceClient).GetUsersStats(ctx, &statsService.GetUsersStatsRequest{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users := make([]OnlineUser, 0, len(resp.GetUsers()))
|
|
for _, u := range resp.GetUsers() {
|
|
if u == nil || u.GetEmail() == "" {
|
|
continue
|
|
}
|
|
ips := make([]OnlineIP, 0, len(u.GetIps()))
|
|
for _, entry := range u.GetIps() {
|
|
if entry == nil || entry.GetIp() == "" {
|
|
continue
|
|
}
|
|
ips = append(ips, OnlineIP{IP: entry.GetIp(), LastSeen: entry.GetLastSeen()})
|
|
}
|
|
users = append(users, OnlineUser{Email: u.GetEmail(), 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
|
|
}
|