mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-13 23:01:00 +00:00
feat(online): use xray online-stats API for onlines and access-log-free IP limit
Adopt xray-core's statsUserOnline policy and GetUsersStats RPC so online detection is connection-based and IP limiting no longer requires an access log. Falls back to the legacy traffic-delta onlines and access-log parsing when the running core lacks the RPCs (Unimplemented), probed lazily per process so a panel-driven version switch re-evaluates automatically. Backend: - xray/api.go: GetOnlineUsers (one GetUsersStats call returns all online users and their source IPs) and IsUnimplementedErr. - xray/process.go: per-process OnlineAPISupport tri-state capability cache. - service/xray.go: ensureStatsPolicy injects statsUserOnline into every policy level of the generated config; XrayService.GetOnlineUsers probes and falls back. - job/xray_traffic_job.go: union API onlines into the delta-derived active set; bump last_online for idle-but-connected clients. - job/check_client_ip_job.go: API-first IP source with shared enforcement; live observations bypass the 30-min stale cutoff; access-log path unchanged for older cores. - service/setting.go: GetIpLimitEnable always true; new accessLogEnable default for features that genuinely read the access log. Frontend: - Client form split into Basic and Config tabs; IP Limit and IP Log no longer gated on access log; compact Auto Renew next to Start After First Use; tabBasic/tabConfig added to all 13 locales. - Xray logs button on the dashboard now gated on accessLogEnable.
This commit is contained in:
+60
-2
@@ -33,7 +33,9 @@ import (
|
||||
"github.com/xtls/xray-core/proxy/vless"
|
||||
"github.com/xtls/xray-core/proxy/vmess"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics.
|
||||
@@ -289,8 +291,8 @@ type RouteTestRequest struct {
|
||||
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"`
|
||||
Matched bool `json:"matched"`
|
||||
OutboundTag string `json:"outboundTag"`
|
||||
// GroupTags lists the balancer chain the decision went through, when any.
|
||||
GroupTags []string `json:"groupTags,omitempty"`
|
||||
}
|
||||
@@ -571,6 +573,62 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
|
||||
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"
|
||||
|
||||
@@ -53,8 +53,12 @@ func TestXrayAPI_E2E(t *testing.T) {
|
||||
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
|
||||
},
|
||||
},
|
||||
"policy": map[string]any{},
|
||||
"stats": map[string]any{},
|
||||
"policy": map[string]any{
|
||||
"levels": map[string]any{
|
||||
"0": map[string]any{"statsUserOnline": true},
|
||||
},
|
||||
},
|
||||
"stats": map[string]any{},
|
||||
}
|
||||
cfgBytes, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
@@ -130,6 +134,19 @@ func TestXrayAPI_E2E(t *testing.T) {
|
||||
t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
|
||||
}
|
||||
|
||||
// --- online-stats API ---
|
||||
// statsUserOnline is enabled in the policy above; with no client
|
||||
// connections the call must succeed and return an empty set. This proves
|
||||
// the GetUsersStats plumbing against a real core (an older binary would
|
||||
// return Unimplemented here — see IsUnimplementedErr).
|
||||
online, err := api.GetOnlineUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("GetOnlineUsers: %v", err)
|
||||
}
|
||||
if len(online) != 0 {
|
||||
t.Fatalf("expected no online users on an idle core, got %+v", online)
|
||||
}
|
||||
|
||||
// --- routing (rules + balancers replace) ---
|
||||
newRouting := []byte(`{
|
||||
"domainStrategy": "AsIs",
|
||||
|
||||
@@ -129,3 +129,21 @@ func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
|
||||
t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlineAPISupportTriState pins the lazy capability probe contract: a new
|
||||
// process starts Unknown (so the first caller probes), and the flag holds
|
||||
// whatever the probe recorded until the process is replaced on restart.
|
||||
func TestOnlineAPISupportTriState(t *testing.T) {
|
||||
p := newOnlineTestProcess()
|
||||
if got := p.OnlineAPISupport(); got != OnlineAPIUnknown {
|
||||
t.Fatalf("new process must start with OnlineAPIUnknown, got %v", got)
|
||||
}
|
||||
p.SetOnlineAPISupport(OnlineAPISupported)
|
||||
if got := p.OnlineAPISupport(); got != OnlineAPISupported {
|
||||
t.Fatalf("expected OnlineAPISupported, got %v", got)
|
||||
}
|
||||
p.SetOnlineAPISupport(OnlineAPIUnsupported)
|
||||
if got := p.OnlineAPISupport(); got != OnlineAPIUnsupported {
|
||||
t.Fatalf("expected OnlineAPIUnsupported, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,12 @@ type process struct {
|
||||
nodeOnlineTrees map[int]map[string][]string
|
||||
onlineMu sync.RWMutex
|
||||
|
||||
// onlineAPISupport caches whether the running core implements the
|
||||
// online-stats RPCs (GetUsersStats). A new process is created on every
|
||||
// restart/version switch, so the flag resets to Unknown and is re-probed
|
||||
// lazily by the first caller.
|
||||
onlineAPISupport atomic.Int32
|
||||
|
||||
config *Config
|
||||
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
|
||||
logWriter *LogWriter
|
||||
@@ -181,6 +187,29 @@ type process struct {
|
||||
intentionalStop atomic.Bool
|
||||
}
|
||||
|
||||
// OnlineAPISupport describes whether the running Xray core implements the
|
||||
// online-stats API (statsUserOnline + GetUsersStats).
|
||||
type OnlineAPISupport int32
|
||||
|
||||
const (
|
||||
// OnlineAPIUnknown means support has not been probed yet for this process.
|
||||
OnlineAPIUnknown OnlineAPISupport = iota
|
||||
// OnlineAPISupported means the core answered the online-stats RPC.
|
||||
OnlineAPISupported
|
||||
// OnlineAPIUnsupported means the core returned Unimplemented (older binary).
|
||||
OnlineAPIUnsupported
|
||||
)
|
||||
|
||||
// OnlineAPISupport returns the cached online-stats capability of this process.
|
||||
func (p *process) OnlineAPISupport() OnlineAPISupport {
|
||||
return OnlineAPISupport(p.onlineAPISupport.Load())
|
||||
}
|
||||
|
||||
// SetOnlineAPISupport records the probed online-stats capability of this process.
|
||||
func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
|
||||
p.onlineAPISupport.Store(int32(v))
|
||||
}
|
||||
|
||||
var (
|
||||
xrayGracefulStopTimeout = 5 * time.Second
|
||||
xrayForceStopTimeout = 2 * time.Second
|
||||
|
||||
Reference in New Issue
Block a user