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:
MHSanaei
2026-06-11 19:42:03 +02:00
parent 58905d81a4
commit 7bcc5830c6
33 changed files with 790 additions and 285 deletions
+60 -2
View File
@@ -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"