mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-12 06:10:58 +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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}]}`),
|
||||
|
||||
Reference in New Issue
Block a user