mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-24 13:36:24 +00:00
892c06c8bc
* fix(xray): block private-range egress in default freedom finalRules (#6037)
With domainStrategy AsIs the router never resolves domains, so a domain
with a private A record (e.g. 127-0-0-1.nip.io) sails past the
geoip:private routing block and freedom's allow-all finalRules let it
reach loopback services such as the xray gRPC API and metrics listener.
Prepend a block rule for geoip:private to the default template and add
the FreedomFinalRulesPrivateEgressBlock seeder so existing installs
still carrying the stock allow-only (or legacy private-only-allow)
finalRules are upgraded in place; customized rules are left untouched.
* fix(sub): version-gate unencrypted-outbound drops in outbound subscriptions (#6033)
Commit d38c912d taught CheckXrayConfig to keep unencrypted vless/trojan
outbounds when the running core predates the v26.7.11 rejection, but
filterOutboundsRejectedByCore still consulted the embedded validator
unconditionally, so outbound subscriptions kept silently dropping those
outbounds even on downgraded cores.
Apply the same shouldSkipLegacyUnencryptedOutboundRejection gate when
filtering fetched subscription outbounds.
* fix(xray): resolve geodata assets before building outbound configs (#5928)
Saving routing or template settings validates each outbound through the
embedded config loader, and a freedom outbound whose finalRules
reference geoip:private opens geoip.dat during that build. Unlike
ApplyRoutingConfig, ValidateOutboundConfig and AddOutbound never pointed
the in-process loader at the bin folder, so xray-core resolved the file
relative to the panel executable and saving failed with
'stat /usr/local/x-ui/geoip.dat: no such file or directory'.
Call ensureXrayAssetLocation before both build paths.
* fix(api): use a real i18n key in the client get handler (#5911)
The client fetch endpoint localized its error prefix with the bare key
'get', which exists in no translation file, so every lookup of a deleted
client's email logged 'message "get" not found in language ...' noise
alongside the expected record-not-found warning. Reuse the same
pages.inbounds.toasts.obtain key the sibling list handler uses.
* fix(sub): carry host record Host header and path into Clash/JSON output (#5944)
The raw-link path overrides the host/path share params from a Host
record via applyEndpointHostPath, but the Clash and JSON renderers read
the transport settings object, which applyHostStreamOverrides never
touched — so a Host record's WebSocket Host header (and path) silently
vanished from Clash/Mihomo and JSON subscriptions whenever the inbound's
own ws settings left them empty.
Inject hostHeader/path into the ws/httpupgrade/xhttp settings of the
per-host stream, mirroring the raw-link override.
* fix(metrics): accept Unicode outbound tags in the observatory (#5972)
The observatory validator whitelisted ASCII word characters, so any
outbound whose tag carries a flag emoji or other non-ASCII text was
silently dropped from the metrics snapshot, delay history, and health
notifications. The history store is an in-process map, so the strict
charset bought nothing.
Validate tags as non-empty, bounded, control-character-free UTF-8
instead, keeping spaces and emoji while still rejecting garbage input on
the query path.
* fix(database): default sqlite to WAL to stop background-job lock storms (#6057, #6068)
With journal_mode=DELETE every write serializes the whole database and
blocks readers, so under normal multi-job load (traffic sampling, node
sync, mtproto reconcile) transactions regularly outwaited the 10s busy
timeout and jobs failed with 'database is locked'.
Move to WAL by default: readers no longer block writers and vice versa,
which removes the observed contention while writer-writer access still
serializes safely. The single-file-at-rest property is preserved where
it matters — Checkpoint() now issues wal_checkpoint(TRUNCATE), so panel
and Telegram backups read a complete main file, and sqlite folds the WAL
back into the db on clean shutdown. XUI_DB_JOURNAL_MODE=DELETE restores
the previous behavior for setups that copy the live file directly.
* fix(database): strip finalmask.tcp from REALITY inbounds on upgrade (#6038)
validateFinalMaskRealityCombo blocks saving finalmask.tcp together with
REALITY because that combination crashes Xray-core 26.7.11 on the first
connection (XTLS/Xray-core#6453), but it only runs on add/update. An
inbound saved before the validator existed sailed through the upgrade
untouched and took the core down at boot.
Add the InboundRealityFinalmaskTcpStrip seeder: one-time scan that
removes finalmask.tcp from REALITY inbounds (other finalmask transports
survive), so upgraded panels start cleanly.
* fix(xray): stop deleting hand-written direct routing rules on save (#6056)
The DNS allow-rule sync recognized 'its' rules purely by shape
(type=field, ip, port, outboundTag=direct, nothing else), so any manual
rule of that shape — e.g. routing a LAN CIDR to a NAS port over direct —
was silently stripped on every settings save.
Mark managed rules with ruleTag=xui-dns-allow (round-tripped untouched
by both xray-core and the Routing tab editor) and only strip rules that
carry the tag. Untagged legacy managed rules are adopted when their
exact ip-set/port matches a currently configured private DNS endpoint;
anything else is left alone. A stale pre-tag managed rule whose DNS
server was removed now lingers until deleted manually — the safe side of
the trade against eating user rules.
* fix(clients): resolve email lookups through client_inbounds after a move (#6059)
GetClientInboundByEmail trusted the client_traffics.inbound_id pointer
whenever that inbound still existed, but a client moved between inbounds
leaves the row pointing at its old (still existing) inbound. The lookup
then searched the wrong inbound's clients and failed with 'Client Not
Found In Inbound For Email', which broke the Telegram bot's link and QR
generation for moved clients.
When the pointed-at inbound no longer carries the email, re-resolve
through the authoritative client_inbounds link to the inbound that
actually hosts the client.
* fix(nodes): replicate inbound fallbacks to nodes (#5963)
Fallbacks live in the inbound_fallbacks table and were only merged into
settings by the master's local config builder; the runtime inbound
pushed to nodes rebuilt settings without them, and the reconcile job
additionally fingerprinted the raw DB row, so fallback edits neither
reached nodes nor triggered a re-push.
Inject settings.fallbacks in buildRuntimeInboundForAPI (mirroring the
local builder, gated on inboundCanHostFallbacks) and make ReconcileNode
push and fingerprint that same runtime-built payload, aligning the
interactive and reconcile paths.
* fix(database): survive PostgreSQL outages without a runaway restart loop (#6023)
A PostgreSQL that was down or still starting made InitDB fail instantly;
the process exited with a generic startup error and systemd restarted it
every 5s forever, flooding the journal.
Retry the initial postgres connection with backoff (~70s total) and log
the real driver error on every attempt, and cap the systemd units with
StartLimitIntervalSec/StartLimitBurst so a persistently unreachable
database stops the unit instead of looping indefinitely.
* fix(xray): force a full restart when REALITY stream settings change (#6010)
A changed inbound is normally hot-swapped over gRPC as RemoveInbound +
AddInbound, but xray-core does not reliably rebuild a REALITY listener's
authenticator on a runtime re-add — key or shortId edits appeared
applied yet clients kept authenticating against the old parameters until
someone restarted the core manually, on nodes in particular.
Treat any non-client change to an inbound that uses (or starts using)
REALITY as not hot-appliable so the panel restarts the core instead.
Client-only edits on REALITY inbounds keep flowing through the per-user
AlterInbound path and still avoid restarts.
* feat(sub): allow insecure TLS for outbound subscription fetches (#6067)
An outbound subscription served over HTTPS with a self-signed or
private-CA certificate could never be fetched: the fetch client had no
TLS options, so refreshes died with 'x509: certificate signed by unknown
authority' and there was nothing the admin could toggle.
Add a per-subscription 'Allow insecure' switch (persisted as
allow_insecure, default off) that sets InsecureSkipVerify on the fetch
transport — including when the fetch is routed through the panel egress
proxy. The SSRF-guarded dialer and redirect re-validation stay in force
either way.
* fix(reality): send PROXY protocol header in the target scanner when xver is set (#6082)
The REALITY target scanner always probed with a plain TLS handshake, so
a target fronted by an Nginx listener that requires the PROXY protocol
(matching the inbound's xver>=1) reset the connection and the panel
reported a false 'TLS handshake failed'.
Thread the inbound's xver into the scan request and, when it is >=1,
lead with the matching PROXY protocol header (v1 for xver 1, binary v2
for xver 2) built from the dialed connection's own address pair. Batch
candidate scans against public sites are unaffected (xver 0).
* fix(frontend): default sockopt fields when editing a stored inbound (#5956)
Opening an existing inbound ran rawInboundToFormValues over the raw DB
row, and only xhttpSettings was re-parsed through its Zod schema to fill
defaults. A sockopt object saved before the TProxy control existed has
no tproxy key, so the Select rendered blank; picking Off didn't help
because the wire normalizer drops tproxy=off, recreating the missing
key on the next edit.
Re-parse streamSettings.sockopt through SockoptStreamSettingsSchema on
load, mirroring the xhttpSettings handling, so absent keys (tproxy,
tcpcongestion, …) get their schema defaults every time the form opens.
827 lines
25 KiB
Go
827 lines
25 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")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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,
|
|
}
|
|
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 := getOptionalUserString(user, "cipher")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
password, err := getRequiredUserString(user, "password")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ssCipherType shadowsocks.CipherType
|
|
switch cipher {
|
|
case "aes-128-gcm":
|
|
ssCipherType = shadowsocks.CipherType_AES_128_GCM
|
|
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_UNKNOWN
|
|
}
|
|
|
|
if ssCipherType != shadowsocks.CipherType_UNKNOWN {
|
|
return serial.ToTypedMessage(&shadowsocks.Account{
|
|
Password: password,
|
|
CipherType: ssCipherType,
|
|
}), nil
|
|
}
|
|
return serial.ToTypedMessage(&shadowsocks_2022.Account{
|
|
Key: password,
|
|
}), 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.
|
|
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
|
|
|
|
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, optionally resetting counters.
|
|
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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|