mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 09:06:07 +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:
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -27,10 +28,14 @@ type IPWithTimestamp struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
|
||||
// CheckClientIpJob monitors client IP addresses and manages IP blocking based
|
||||
// on configured limits. The per-client IPs come from the core's online-stats
|
||||
// API when the running core supports it (no access log needed), falling back
|
||||
// to access-log parsing on older cores.
|
||||
type CheckClientIpJob struct {
|
||||
lastClear int64
|
||||
disAllowedIps []string
|
||||
xrayService service.XrayService
|
||||
}
|
||||
|
||||
var job *CheckClientIpJob
|
||||
@@ -50,22 +55,32 @@ func (j *CheckClientIpJob) Run() {
|
||||
j.lastClear = time.Now().Unix()
|
||||
}
|
||||
|
||||
shouldClearAccessLog := false
|
||||
fail2BanEnabled := isFail2BanEnabled()
|
||||
hasLimit := fail2BanEnabled && j.hasLimitIp()
|
||||
f2bInstalled := false
|
||||
if hasLimit {
|
||||
f2bInstalled = j.checkFail2BanInstalled()
|
||||
}
|
||||
|
||||
if observed, apiMode := j.collectFromOnlineAPI(); apiMode {
|
||||
if fail2BanEnabled {
|
||||
j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
|
||||
}
|
||||
// The core tracks online IPs itself, so no access log is needed in this
|
||||
// mode; still rotate a user-configured access log hourly so it doesn't
|
||||
// grow unboundedly. The enforcement-triggered rotation is skipped —
|
||||
// nothing here reads the log.
|
||||
if j.checkAccessLogAvailable(false) && time.Now().Unix()-j.lastClear > 3600 {
|
||||
j.clearAccessLog()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
shouldClearAccessLog := false
|
||||
isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
|
||||
|
||||
if fail2BanEnabled && isAccessLogAvailable {
|
||||
enforce := hasLimit
|
||||
if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
|
||||
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
||||
enforce = false
|
||||
}
|
||||
shouldClearAccessLog = j.processLogFile(enforce)
|
||||
shouldClearAccessLog = j.processLogFile(j.resolveEnforce(hasLimit, f2bInstalled))
|
||||
}
|
||||
|
||||
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
||||
@@ -73,6 +88,50 @@ func (j *CheckClientIpJob) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEnforce decides whether limits can actually be enforced this run,
|
||||
// warning when fail2ban is missing on a platform that needs it.
|
||||
func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
|
||||
if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
|
||||
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
||||
return false
|
||||
}
|
||||
return hasLimit
|
||||
}
|
||||
|
||||
// collectFromOnlineAPI builds per-email IP observations (email -> ip ->
|
||||
// last-seen unix seconds) from the core's online-stats API. ok=false means the
|
||||
// API is unavailable — xray not running, an older core, or a transient gRPC
|
||||
// failure — and the caller must fall back to access-log parsing.
|
||||
func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
|
||||
onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
|
||||
if err != nil {
|
||||
logger.Debug("[LimitIP] online-stats API unavailable this run:", err)
|
||||
return nil, false
|
||||
}
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
observed := make(map[string]map[string]int64, len(onlineUsers))
|
||||
for _, user := range onlineUsers {
|
||||
for _, entry := range user.IPs {
|
||||
// No localhost guard needed here: the core's OnlineMap.AddIP drops
|
||||
// 127.0.0.1/[::1] itself, so they never reach this list.
|
||||
ts := entry.LastSeen
|
||||
if ts <= 0 {
|
||||
ts = now
|
||||
}
|
||||
if _, exists := observed[user.Email]; !exists {
|
||||
observed[user.Email] = make(map[string]int64)
|
||||
}
|
||||
if existing, seen := observed[user.Email][entry.IP]; !seen || ts > existing {
|
||||
observed[user.Email][entry.IP] = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
return observed, true
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) clearAccessLog() {
|
||||
logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
j.checkError(err)
|
||||
@@ -183,18 +242,26 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
||||
j.checkError(err)
|
||||
}
|
||||
|
||||
shouldCleanLog := false
|
||||
for email, ipTimestamps := range inboundClientIps {
|
||||
return j.processObserved(inboundClientIps, enforce, false)
|
||||
}
|
||||
|
||||
// The access log can still reference a client that was just renamed
|
||||
// processObserved runs collection + enforcement for one scan's observations
|
||||
// (email -> ip -> last-seen unix seconds). observedAreLive marks the
|
||||
// observations as live connections (online-stats API) rather than recent log
|
||||
// lines: live entries bypass the stale cutoff, since a connection that opened
|
||||
// hours ago is still live even though its timestamp is old.
|
||||
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
|
||||
shouldCleanLog := false
|
||||
for email, ipTimestamps := range observed {
|
||||
|
||||
// The observations can still reference a client that was just renamed
|
||||
// or deleted; its email no longer matches any inbound. Skip it (and
|
||||
// drop any orphaned tracking row) instead of recreating a row and
|
||||
// logging an ERROR every run until the log rotates out the old email
|
||||
// (#4963).
|
||||
// logging an ERROR every run (#4963).
|
||||
inbound, err := j.getInboundByEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
|
||||
logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
|
||||
j.delInboundClientIps(email)
|
||||
} else {
|
||||
j.checkError(err)
|
||||
@@ -214,13 +281,17 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
|
||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
|
||||
}
|
||||
|
||||
return shouldCleanLog
|
||||
}
|
||||
|
||||
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
|
||||
// mergeClientIps folds this scan's observations into the persisted set,
|
||||
// dropping entries older than staleCutoff. newAlwaysLive exempts the new
|
||||
// entries from that cutoff: an API-observed IP is a live connection by
|
||||
// definition, even when its lastSeen (set at dispatch time) is hours old.
|
||||
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
|
||||
ipMap := make(map[string]int64, len(old)+len(new))
|
||||
for _, ipTime := range old {
|
||||
if ipTime.Timestamp < staleCutoff {
|
||||
@@ -229,7 +300,7 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
||||
ipMap[ipTime.IP] = ipTime.Timestamp
|
||||
}
|
||||
for _, ipTime := range new {
|
||||
if ipTime.Timestamp < staleCutoff {
|
||||
if !newAlwaysLive && ipTime.Timestamp < staleCutoff {
|
||||
continue
|
||||
}
|
||||
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
|
||||
@@ -239,6 +310,16 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
||||
return ipMap
|
||||
}
|
||||
|
||||
// selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
|
||||
// into the newest `limit` entries to keep and the older remainder to ban.
|
||||
func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
|
||||
if limit <= 0 || len(live) <= limit {
|
||||
return live, nil
|
||||
}
|
||||
cutoff := len(live) - limit
|
||||
return live[cutoff:], live[:cutoff]
|
||||
}
|
||||
|
||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||
@@ -343,7 +424,7 @@ func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
|
||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) bool {
|
||||
if inbound.Settings == "" {
|
||||
logger.Debug("wrong data:", inbound)
|
||||
return false
|
||||
@@ -380,7 +461,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||
}
|
||||
|
||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
|
||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
|
||||
|
||||
// only ips seen in this scan count toward the limit. see
|
||||
// partitionLiveIps.
|
||||
@@ -394,15 +475,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
j.disAllowedIps = []string{}
|
||||
|
||||
// historical db-only ips are excluded from this count on purpose.
|
||||
var keptLive []IPWithTimestamp
|
||||
if len(liveIps) > limitIp {
|
||||
keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
|
||||
if len(bannedLive) > 0 {
|
||||
shouldCleanLog = true
|
||||
|
||||
// keep the newest live ips, ban older ones.
|
||||
cutoff := len(liveIps) - limitIp
|
||||
keptLive = liveIps[cutoff:]
|
||||
bannedLive := liveIps[:cutoff]
|
||||
|
||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||
@@ -422,8 +498,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||
|
||||
// force xray to drop existing connections from banned ips
|
||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
||||
} else {
|
||||
keptLive = liveIps
|
||||
}
|
||||
|
||||
// keep kept-live + historical in the blob so the panel keeps showing
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundByEmail: %v", err)
|
||||
}
|
||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
|
||||
|
||||
if shouldCleanLog {
|
||||
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
||||
@@ -252,7 +252,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundByEmail: %v", err)
|
||||
}
|
||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
||||
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
|
||||
|
||||
if !shouldCleanLog {
|
||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
||||
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
||||
}
|
||||
|
||||
got := mergeClientIps(old, new, 1000)
|
||||
got := mergeClientIps(old, new, 1000, false)
|
||||
|
||||
want := map[string]int64{"2.2.2.2": 2000}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -36,7 +36,7 @@ func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
||||
old := []IPWithTimestamp{
|
||||
{IP: "1.1.1.1", Timestamp: 1500},
|
||||
}
|
||||
got := mergeClientIps(old, nil, 1000)
|
||||
got := mergeClientIps(old, nil, 1000, false)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 1500}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -48,7 +48,7 @@ func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
||||
|
||||
got := mergeClientIps(old, new, 1000)
|
||||
got := mergeClientIps(old, new, 1000, false)
|
||||
|
||||
if got["1.1.1.1"] != 1700 {
|
||||
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
|
||||
@@ -59,7 +59,7 @@ func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
||||
// A log line with a clock-skewed old timestamp must not resurrect a
|
||||
// stale IP past the cutoff.
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
||||
got := mergeClientIps(nil, new, 1000)
|
||||
got := mergeClientIps(nil, new, 1000, false)
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("stale new IP should have been dropped, got %v", got)
|
||||
@@ -72,7 +72,7 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
||||
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
||||
|
||||
got := mergeClientIps(old, new, 0)
|
||||
got := mergeClientIps(old, new, 0, false)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -80,6 +80,66 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
|
||||
// online-API mode: lastSeen is set when the connection was dispatched, so
|
||||
// a connection held open for hours has an "old" timestamp while being live
|
||||
// by definition. It must survive the stale cutoff.
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
|
||||
got := mergeClientIps(nil, new, 1000, true)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 500}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("live observation must bypass the stale cutoff\ngot: %v\nwant: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
|
||||
// the bypass applies only to this scan's observations — persisted entries
|
||||
// from past scans still age out as before.
|
||||
old := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 100}}
|
||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 2000}}
|
||||
got := mergeClientIps(old, new, 1000, true)
|
||||
|
||||
want := map[string]int64{"1.1.1.1": 2000}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("stale db entry must still be evicted in live mode\ngot: %v\nwant: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectIpsToBan(t *testing.T) {
|
||||
live := []IPWithTimestamp{ // sorted oldest-first, as partitionLiveIps returns
|
||||
{IP: "A", Timestamp: 100},
|
||||
{IP: "B", Timestamp: 200},
|
||||
{IP: "C", Timestamp: 300},
|
||||
}
|
||||
|
||||
// over the limit: oldest connections are banned, newest keep the slots
|
||||
kept, banned := selectIpsToBan(live, 1)
|
||||
if got := collectIps(kept); !reflect.DeepEqual(got, []string{"C"}) {
|
||||
t.Fatalf("newest ip must keep the slot, got %v", got)
|
||||
}
|
||||
if got := collectIps(banned); !reflect.DeepEqual(got, []string{"A", "B"}) {
|
||||
t.Fatalf("older ips must be banned oldest-first, got %v", got)
|
||||
}
|
||||
|
||||
// at the limit: nothing banned
|
||||
kept, banned = selectIpsToBan(live, 3)
|
||||
if len(banned) != 0 || len(kept) != 3 {
|
||||
t.Fatalf("at-limit set must not ban, kept=%v banned=%v", kept, banned)
|
||||
}
|
||||
|
||||
// under the limit: nothing banned
|
||||
kept, banned = selectIpsToBan(live[:1], 3)
|
||||
if len(banned) != 0 || len(kept) != 1 {
|
||||
t.Fatalf("under-limit set must not ban, kept=%v banned=%v", kept, banned)
|
||||
}
|
||||
|
||||
// defensive: non-positive limit never reaches enforcement, but must not panic
|
||||
if _, banned := selectIpsToBan(live, 0); banned != nil {
|
||||
t.Fatalf("zero limit must not ban, got %v", banned)
|
||||
}
|
||||
}
|
||||
|
||||
func collectIps(entries []IPWithTimestamp) []string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -66,21 +66,37 @@ func (j *XrayTrafficJob) Run() {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
|
||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
logger.Warning("get clients last online failed:", err)
|
||||
}
|
||||
if lastOnlineMap == nil {
|
||||
lastOnlineMap = make(map[string]int64)
|
||||
}
|
||||
// Derive the local online set from this poll's per-email deltas rather
|
||||
// than the shared last_online column, which remote-node syncs also bump
|
||||
// and would otherwise make a client active only on a remote node appear
|
||||
// online on local inbounds.
|
||||
activeEmails := make([]string, 0, len(clientTraffics))
|
||||
deltaActive := make(map[string]bool, len(clientTraffics))
|
||||
for _, ct := range clientTraffics {
|
||||
if ct != nil && ct.Up+ct.Down > 0 {
|
||||
activeEmails = append(activeEmails, ct.Email)
|
||||
deltaActive[ct.Email] = true
|
||||
}
|
||||
}
|
||||
// When the core supports the online-stats API, union in connection-based
|
||||
// onlines. Neither signal alone covers everything: an idle-but-connected
|
||||
// client moves no bytes between polls (the delta heuristic's blind spot),
|
||||
// while a short-lived connection can close before this poll yet still show
|
||||
// in the delta. Older cores fall back to deltas alone.
|
||||
if onlineUsers, apiMode, ouErr := j.xrayService.GetOnlineUsers(); ouErr != nil {
|
||||
logger.Debug("get online users from xray api failed:", ouErr)
|
||||
} else if apiMode {
|
||||
idleOnline := make([]string, 0, len(onlineUsers))
|
||||
for _, u := range onlineUsers {
|
||||
if !deltaActive[u.Email] {
|
||||
activeEmails = append(activeEmails, u.Email)
|
||||
idleOnline = append(idleOnline, u.Email)
|
||||
}
|
||||
}
|
||||
// The traffic path only bumps last_online on a non-zero delta; keep the
|
||||
// column fresh for clients kept online purely by a live connection.
|
||||
if err := j.inboundService.BumpClientsLastOnline(idleOnline); err != nil {
|
||||
logger.Warning("bump last online for connected clients failed:", err)
|
||||
}
|
||||
}
|
||||
// Pair the email signal with the inbound tags that moved bytes this poll.
|
||||
@@ -100,6 +116,13 @@ func (j *XrayTrafficJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
|
||||
if err != nil {
|
||||
logger.Warning("get clients last online failed:", err)
|
||||
}
|
||||
if lastOnlineMap == nil {
|
||||
lastOnlineMap = make(map[string]int64)
|
||||
}
|
||||
onlineClients := j.inboundService.GetOnlineClients()
|
||||
if onlineClients == nil {
|
||||
onlineClients = []string{}
|
||||
|
||||
@@ -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"}]}`),
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "أساسي",
|
||||
"tabConfig": "التكوين",
|
||||
"add": "إضافة عميل",
|
||||
"edit": "تعديل العميل",
|
||||
"submitAdd": "إضافة عميل",
|
||||
|
||||
@@ -628,6 +628,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Basic",
|
||||
"tabConfig": "Config",
|
||||
"add": "Add Client",
|
||||
"edit": "Edit Client",
|
||||
"submitAdd": "Add Client",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Básico",
|
||||
"tabConfig": "Configuración",
|
||||
"add": "Añadir cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Añadir cliente",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "پایه",
|
||||
"tabConfig": "پیکربندی",
|
||||
"add": "افزودن کلاینت",
|
||||
"edit": "ویرایش کلاینت",
|
||||
"submitAdd": "افزودن کلاینت",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Dasar",
|
||||
"tabConfig": "Konfigurasi",
|
||||
"add": "Tambah klien",
|
||||
"edit": "Ubah klien",
|
||||
"submitAdd": "Tambah klien",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "基本",
|
||||
"tabConfig": "設定",
|
||||
"add": "クライアントを追加",
|
||||
"edit": "クライアントを編集",
|
||||
"submitAdd": "クライアントを追加",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Básico",
|
||||
"tabConfig": "Configuração",
|
||||
"add": "Adicionar cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Adicionar cliente",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Основные",
|
||||
"tabConfig": "Конфигурация",
|
||||
"add": "Добавить клиента",
|
||||
"edit": "Изменить клиента",
|
||||
"submitAdd": "Добавить клиента",
|
||||
|
||||
@@ -628,6 +628,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Temel",
|
||||
"tabConfig": "Yapılandırma",
|
||||
"add": "Kullanıcı Ekle",
|
||||
"edit": "Kullanıcıyı Düzenle",
|
||||
"submitAdd": "Kullanıcı Ekle",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Основні",
|
||||
"tabConfig": "Конфігурація",
|
||||
"add": "Додати клієнта",
|
||||
"edit": "Редагувати клієнта",
|
||||
"submitAdd": "Додати клієнта",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "Cơ bản",
|
||||
"tabConfig": "Cấu hình",
|
||||
"add": "Thêm khách hàng",
|
||||
"edit": "Chỉnh sửa khách hàng",
|
||||
"submitAdd": "Thêm khách hàng",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "基本",
|
||||
"tabConfig": "配置",
|
||||
"add": "添加客户端",
|
||||
"edit": "编辑客户端",
|
||||
"submitAdd": "添加客户端",
|
||||
|
||||
@@ -627,6 +627,8 @@
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"tabBasic": "基本",
|
||||
"tabConfig": "配置",
|
||||
"add": "新增客戶端",
|
||||
"edit": "編輯客戶端",
|
||||
"submitAdd": "新增客戶端",
|
||||
|
||||
Reference in New Issue
Block a user