mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
Wraps up the 'help wanted' backend items from the SOCKS5 scaffold PR (#4452) so the dedicated socks inbound is a fully functional protocol end-to-end, not just a model constant. xray/api.go AddUser ------------------- Live add-user via the gRPC HandlerService now handles 'socks' and 'http' as first-class protocols. Previously these fell through the default branch and returned nil, so adding a new password-mode account to a running socks/http inbound silently required a full xray restart. - New 'socks' case constructs a proxy/socks.Account{Username, Password} from the panel-side map keys 'user' and 'pass' (matching how Inbound.SocksSettings.SocksAccount serialises in frontend/src/models/inbound.js). Username is required, password is optional so a no-pass account is still expressible if Xray ever allows it on a specific build. - New 'http' case mirrors the same shape via proxy/http.Account. The dedicated HTTP inbound isn't surfaced standalone in the panel UI yet, but the runtime API is symmetric with socks and several follow-up plans (e.g. exposing HTTP as a separate inbound) become one-line UI work instead of a backend refactor. Both branches reuse the existing getRequiredUserString / getOptionalUserString helpers, so a malformed userMap surfaces the same typed error message as the vless / vmess paths above. web/service/port_conflict.go ---------------------------- inboundTransports() now folds 'socks' into the same branch that already handles 'mixed': settings.udp=true means the inbound holds both tcp and udp on the listening port (socks5 UDP ASSOCIATE), settings.udp=false keeps it tcp-only. Without this, a socks+udp inbound would silently be classified as tcp-only and the validator would let a hysteria2 udp inbound coexist with it on the same port — both processes would then race for the udp socket at xray start, with one of them quietly failing. The two protocols share the exact same settings JSON shape for this field (it's the same proxy/socks server type under the hood), so the sane thing is to merge the case clauses rather than copy/paste the type-assertion. Comment updated to spell out why. web/service/tgbot.go -------------------- Add model.Socks to the excludedProtocols set in getInboundsAddClient so the Telegram bot doesn't offer a dedicated SOCKS inbound when the admin asks 'add a client to which inbound?'. SOCKS inbounds, like Mixed/HTTP, don't produce a per-client subscription URL (see the existing link-less branch in sub/subService.go::GetLink), so any client attached via the bot would have no way to actually subscribe. Added a header comment explaining the criterion so future protocols fall into the right bucket without an audit. Tests ----- web/service/port_conflict_test.go gains four cases that pin the new behaviour at the transport-bits level (TestInboundTransports): - socks + udp=true -> tcp|udp (matches Mixed) - socks + udp=false -> tcp only - socks + missing settings -> tcp only - socks + empty settings -> tcp only …plus two end-to-end conflict checks that mirror the existing shadowsocks/mixed coverage: - TestCheckPortConflict_SocksUDPBlocksUDPNeighbour: a socks+udp inbound on port N must clash with a hysteria2/udp on the same port. Catches a regression where the Socks branch is dropped from inboundTransports. - TestCheckPortConflict_SocksTCPCoexistsWithUDPNeighbour: a socks-tcp-only inbound must still let a hysteria2/udp neighbour bind the same port. Mirrors the #4103 vless+hysteria2 coexistence case. Out-of-scope (still tracked in the PR description) -------------------------------------------------- - Sub-link generation (sub/subService.go GetLink): SOCKS deliberately stays link-less for the reasons documented in the previous commit; no socks:// scheme is consistently understood across xray/v2ray client ecosystems. - Routing UI: routing rules in this fork already accept any inbound tag, so SOCKS inbounds are routable as-is. A dedicated 'protocol == socks' helper in the routing rule editor is a UX follow-up, not a correctness gap. - Translations: protocol labels are rendered raw in this fork; no per-locale label key exists for vmess/vless/mixed either, so adding one only for socks would be inconsistent.
426 lines
12 KiB
Go
426 lines
12 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"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
|
|
|
"github.com/xtls/xray-core/app/proxyman/command"
|
|
statsService "github.com/xtls/xray-core/app/stats/command"
|
|
"github.com/xtls/xray-core/common/protocol"
|
|
"github.com/xtls/xray-core/common/serial"
|
|
"github.com/xtls/xray-core/infra/conf"
|
|
httpProxy "github.com/xtls/xray-core/proxy/http"
|
|
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/socks"
|
|
"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
|
|
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)
|
|
|
|
x.HandlerServiceClient = &hsClient
|
|
x.StatsServiceClient = &ssClient
|
|
|
|
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.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
|
|
}
|
|
|
|
// 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 "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", "hysteria2":
|
|
auth, err := getRequiredUserString(user, "auth")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
account = serial.ToTypedMessage(&hysteriaAccount.Account{
|
|
Auth: auth,
|
|
})
|
|
case "socks":
|
|
// Xray's dedicated socks5 inbound. Live add-user via the gRPC
|
|
// HandlerService takes a socks.Account whose fields are
|
|
// {username, password} — distinct from the JSON inbound config,
|
|
// where the same data lives under settings.accounts[].{user,pass}.
|
|
// We map the panel-side "user"/"pass" (matching the SocksSettings
|
|
// model in frontend/src/models/inbound.js) onto those wire-level
|
|
// field names here so the runtime sees the credentials in the
|
|
// shape xray-core expects.
|
|
//
|
|
// "username" is treated as required: the dedicated socks inbound
|
|
// in noauth mode doesn't have per-user accounts at all, so any
|
|
// AddUser request we see here is a password-mode user and must
|
|
// carry a non-empty username.
|
|
username, err := getRequiredUserString(user, "user")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
password, err := getOptionalUserString(user, "pass")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
account = serial.ToTypedMessage(&socks.Account{
|
|
Username: username,
|
|
Password: password,
|
|
})
|
|
case "http":
|
|
// Xray's dedicated http inbound exposes its own
|
|
// {username, password} Account in proxy/http. Same wire-level
|
|
// shape as socks (and the same panel-side "user"/"pass" mapping
|
|
// from HttpSettings), but the proto types are distinct, so we
|
|
// can't share one branch — the typed-message wrapper needs the
|
|
// exact proto.Message type the runtime registered under
|
|
// proxy.http inbound. Adding HTTP here keeps the dispatch table
|
|
// symmetric with socks and lets the panel push live-add-user
|
|
// for the dedicated HTTP inbound just like it does for socks.
|
|
username, err := getRequiredUserString(user, "user")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
password, err := getOptionalUserString(user, "pass")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
account = serial.ToTypedMessage(&httpProxy.Account{
|
|
Username: username,
|
|
Password: password,
|
|
})
|
|
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
|
|
}
|