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
+22
View File
@@ -837,6 +837,28 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
return traffics, nil
}
// BumpClientsLastOnline sets client_traffics.last_online to now for the given
// emails. Used in online-API mode for clients that hold a live connection but
// moved no bytes this poll — the traffic path (addClientTraffic) only bumps
// last_online on a non-zero delta, so idle-but-connected clients would
// otherwise show a stale "last online" while being reported online.
func (s *InboundService) BumpClientsLastOnline(emails []string) error {
uniq := uniqueNonEmptyStrings(emails)
if len(uniq) == 0 {
return nil
}
now := time.Now().UnixMilli()
return submitTrafficWrite(func() error {
db := database.GetDB()
for _, batch := range chunkStrings(uniq, sqliteMaxVars) {
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("last_online", now).Error; err != nil {
return err
}
}
return nil
})
}
func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
uniq := uniqueNonEmptyStrings(emails)
if len(uniq) == 0 {
+31 -19
View File
@@ -798,7 +798,18 @@ func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error {
return s.setBool("restartXrayOnClientDisable", value)
}
// GetIpLimitEnable reports whether the IP-limit feature is available. Always
// true since the panel enforces limits via the core's online-stats API; on an
// older core the job falls back to access-log parsing and warns there when the
// log is missing, so the UI no longer hides the field behind that condition.
func (s *SettingService) GetIpLimitEnable() (bool, error) {
return true, nil
}
// GetAccessLogEnable reports whether an Xray access log is configured. Used by
// the UI for features that genuinely read the log file (the xray log viewer) —
// distinct from IP limiting, which works without it.
func (s *SettingService) GetAccessLogEnable() (bool, error) {
accessLogPath, err := xray.GetAccessLogPath()
if err != nil {
return false, err
@@ -1022,25 +1033,26 @@ func (s *SettingService) BuildSubURIBase(host string) string {
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
type settingFunc func() (any, error)
settings := map[string]settingFunc{
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
"webDomain": func() (any, error) { return s.GetWebDomain() },
"subDomain": func() (any, error) { return s.GetSubDomain() },
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
"webDomain": func() (any, error) { return s.GetWebDomain() },
"subDomain": func() (any, error) { return s.GetSubDomain() },
}
result := make(map[string]any)
+83
View File
@@ -116,6 +116,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
}
xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
xrayConfig.API = ensureAPIServices(xrayConfig.API)
xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
_, _, _ = s.inboundService.AddTraffic(nil, nil)
@@ -421,6 +422,51 @@ func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage {
return out
}
// ensureStatsPolicy guarantees every policy level in the generated config has
// statsUserOnline enabled, so the core tracks per-email online IPs for the
// panel's online view and access-log-free IP limiting. Generated clients carry
// no explicit level, so level "0" is created when absent. The flag is panel
// infrastructure and is forced on even over an explicit false in the template,
// same as the api services above. An entirely missing or unparsable policy
// block is left alone; the stored template itself is never modified — only the
// generated runtime config.
func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
if len(policy) == 0 {
return policy
}
var parsed map[string]any
if err := json.Unmarshal(policy, &parsed); err != nil {
return policy
}
levels, _ := parsed["levels"].(map[string]any)
if levels == nil {
levels = make(map[string]any)
}
if _, ok := levels["0"]; !ok {
levels["0"] = map[string]any{}
}
changed := false
for _, raw := range levels {
level, ok := raw.(map[string]any)
if !ok {
continue
}
if enabled, ok := level["statsUserOnline"].(bool); !ok || !enabled {
level["statsUserOnline"] = true
changed = true
}
}
if !changed {
return policy
}
parsed["levels"] = levels
out, err := json.Marshal(parsed)
if err != nil {
return policy
}
return out
}
// resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
// absolute paths under config.GetLogFolder(), so Xray writes those files
// alongside the panel's other logs regardless of the working directory the
@@ -493,6 +539,43 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
return traffic, clientTraffic, nil
}
// GetOnlineUsers returns connection-based online users (email + source IPs)
// from the running core's online-stats API. ok=false means the API is not
// available — xray isn't running or the core predates the online-stats RPCs —
// and callers must use the legacy traffic-delta / access-log paths. The
// capability is probed lazily per process: an Unimplemented answer pins this
// core as unsupported until the next restart, while transient errors leave the
// capability undecided so a flaky poll can't lock in legacy mode.
func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
if !s.IsXrayRunning() {
return nil, false, nil
}
if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
return nil, false, nil
}
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
logger.Debug("Failed to initialize Xray API:", err)
return nil, false, err
}
defer s.xrayAPI.Close()
users, err := s.xrayAPI.GetOnlineUsers()
if err != nil {
if xray.IsUnimplementedErr(err) {
p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
return nil, false, nil
}
logger.Debug("Failed to fetch Xray online users:", err)
return nil, false, err
}
if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
p.SetOnlineAPISupport(xray.OnlineAPISupported)
logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
}
return users, true, nil
}
// BalancerStatus is the live view of one balancer for the panel UI. Running
// is false when the balancer isn't present in the running core (e.g. xray is
// stopped or the balancer hasn't been saved/applied yet).
@@ -54,6 +54,71 @@ func TestEnsureAPIServices(t *testing.T) {
}
}
func TestEnsureStatsPolicy(t *testing.T) {
// default-template shape: level "0" exists with traffic flags — the online
// flag is added and the siblings survive untouched
out := ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"handshake":4,"statsUserUplink":true,"statsUserDownlink":true}},"system":{"statsInboundDownlink":true}}`))
var parsed struct {
Levels map[string]map[string]any `json:"levels"`
System map[string]any `json:"system"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatal(err)
}
level0 := parsed.Levels["0"]
if level0["statsUserOnline"] != true {
t.Fatalf("statsUserOnline must be injected into level 0, got %v", level0)
}
if level0["statsUserUplink"] != true || level0["statsUserDownlink"] != true || level0["handshake"] != float64(4) {
t.Fatalf("sibling keys must be preserved, got %v", level0)
}
if parsed.System["statsInboundDownlink"] != true {
t.Fatalf("system block must be preserved, got %v", parsed.System)
}
// missing levels block: level "0" is created with the flag
out = ensureStatsPolicy(json_util.RawMessage(`{"system":{}}`))
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatal(err)
}
if parsed.Levels["0"]["statsUserOnline"] != true {
t.Fatalf("level 0 must be created with statsUserOnline, got %s", out)
}
// every level gets the flag, an explicit false included — the flag is
// panel infrastructure, like the api services
out = ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":false},"1":{"connIdle":300}}}`))
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatal(err)
}
for _, key := range []string{"0", "1"} {
if parsed.Levels[key]["statsUserOnline"] != true {
t.Fatalf("level %s must have statsUserOnline forced on, got %s", key, out)
}
}
if parsed.Levels["1"]["connIdle"] != float64(300) {
t.Fatalf("level 1 siblings must be preserved, got %s", out)
}
// already-enabled input passes through byte-identical (no marshal churn,
// no spurious restart)
full := json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":true}}}`)
if got := ensureStatsPolicy(full); string(got) != string(full) {
t.Fatalf("already-enabled policy must pass through untouched, got %s", got)
}
// absent policy block stays absent
if got := ensureStatsPolicy(nil); got != nil {
t.Fatalf("nil policy must stay nil, got %s", got)
}
// unparsable policy is left untouched
bad := json_util.RawMessage(`{not json`)
if got := ensureStatsPolicy(bad); string(got) != string(bad) {
t.Fatalf("unparsable policy must be left untouched, got %s", got)
}
}
func egressTestConfig() *xray.Config {
return &xray.Config{
RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),