Files
3x-ui-agent/internal/web/service/xray_metrics.go
T
Muse a654a96c38
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

Xray更新后端(经调查确认仅服务本机bin/xray, 与agent下发/订阅无关):
- 删除 getXrayVersion/installXray/updateGeofile 路由与handler(访问返回404)
- 删除 ServerService 对应实现与校验和函数; 同步api-docs/monitor白名单/openapi

本机核心监管下线(设计: 主控永不运行本机xray, 入站全在agent):
- XrayService.RestartXray 降级为no-op(一次性Warning说明日志); StopXray无进程时幂等返回
- web.go 摘除启动拉起本机核心/CheckXrayRunning看门狗(1s)/ApplyPendingRestart cron(30s)/XrayTrafficJob/CheckClientIpJob 注册
- ApplyPendingRestart 降级为消费即清, 其测试更新为新语义
- XrayMetricsService.Sample 短路: 不再每2s探本机/debug/vars, 状态优雅置为不可用
- 配置生成(纯JSON)/agent链路/订阅/入站CRUD未动; 状态读数优雅归零(stop/Unknown)

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 23:39:38 +08:00

331 lines
9.0 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
type xrayMetricsState struct {
Enabled bool `json:"enabled"`
Listen string `json:"listen"`
Reason string `json:"reason,omitempty"`
}
type ObsTagSnapshot struct {
Tag string `json:"tag"`
Alive bool `json:"alive"`
Delay int64 `json:"delay"`
LastSeenTime int64 `json:"lastSeenTime"`
LastTryTime int64 `json:"lastTryTime"`
UpdatedAt int64 `json:"updatedAt"`
}
// eventBus is the shared bus for publishing observatory state-change events.
// Set once during startup via SetEventBus; nil when no bus is configured.
var eventBus *eventbus.Bus
// SetEventBus assigns the global event bus used by applyObservatory to publish
// outbound health transitions. Must be called once during startup before any
// Sample tick runs.
func SetEventBus(b *eventbus.Bus) { eventBus = b }
type XrayMetricsService struct {
settingService SettingService
mu sync.RWMutex
state xrayMetricsState
client *http.Client
obsByTag map[string]ObsTagSnapshot
health map[string]outboundHealth
}
// outboundHealth debounces observatory flapping. Xray flips an outbound's
// alive flag on a single failed probe, so raw transitions produce a storm of
// down/up notifications on a flaky link. We instead require failStreak to reach
// the configured threshold (consecutive FAILED probes, tracked per new probe
// via lastTry) before publishing outbound.down, and only publish outbound.up
// once a down has actually been notified.
type outboundHealth struct {
lastTry int64
failStreak int
notified bool
}
const maxObsTagLength = 128
func validObsTag(tag string) bool {
if tag == "" || len(tag) > maxObsTagLength || !utf8.ValidString(tag) {
return false
}
for _, r := range tag {
if unicode.IsControl(r) {
return false
}
}
return true
}
func obsHistoryKey(tag string) string {
return "xrObs." + tag + ".delay"
}
func newXrayMetricsClient() *http.Client {
return &http.Client{Timeout: 1500 * time.Millisecond}
}
func (s *XrayMetricsService) getClient() *http.Client {
s.mu.Lock()
defer s.mu.Unlock()
if s.client == nil {
s.client = newXrayMetricsClient()
}
return s.client
}
func (s *XrayMetricsService) State() xrayMetricsState {
s.mu.RLock()
defer s.mu.RUnlock()
return s.state
}
func (s *XrayMetricsService) AggregateMetric(metric string, bucketSeconds, maxPoints int) []map[string]any {
return xrayMetrics.aggregate(metric, bucketSeconds, maxPoints)
}
func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]ObsTagSnapshot, 0, len(s.obsByTag))
for _, v := range s.obsByTag {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Tag < out[j].Tag })
return out
}
func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
if !validObsTag(tag) {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.obsByTag[tag]
return ok
}
func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
if !validObsTag(tag) {
return []map[string]any{}
}
return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
}
func (s *XrayMetricsService) discoverListen() (string, error) {
tmpl, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
return "", err
}
var parsed struct {
Metrics *struct {
Listen string `json:"listen"`
} `json:"metrics"`
}
if err := json.Unmarshal([]byte(tmpl), &parsed); err != nil {
return "", err
}
if parsed.Metrics == nil || strings.TrimSpace(parsed.Metrics.Listen) == "" {
return "", nil
}
return strings.TrimSpace(parsed.Metrics.Listen), nil
}
type rawObsEntry struct {
Alive bool `json:"alive"`
Delay int64 `json:"delay"`
LastSeenTime int64 `json:"last_seen_time"`
LastTryTime int64 `json:"last_try_time"`
OutboundTag string `json:"outbound_tag"`
}
func (s *XrayMetricsService) Sample(t time.Time) {
// The local xray core never runs in this build (control-plane only),
// so there is no /debug/vars endpoint to sample. Report the metrics
// feed as disabled and stay silent instead of probing a permanently
// dead endpoint on every 2s status tick.
s.setStateQuiet(xrayMetricsState{Enabled: false, Reason: "local xray core disabled in this build"})
}
func (s *XrayMetricsService) sampleLocalXrayLegacy(t time.Time) {
listen, err := s.discoverListen()
if err != nil {
s.setState(xrayMetricsState{Reason: err.Error()})
return
}
if listen == "" {
s.setState(xrayMetricsState{Reason: "metrics block not configured in xray template"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
url := fmt.Sprintf("http://%s/debug/vars", listen)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
return
}
resp, err := s.getClient().Do(req)
if err != nil {
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.setState(xrayMetricsState{Listen: listen, Reason: fmt.Sprintf("HTTP %d", resp.StatusCode)})
return
}
var payload struct {
MemStats struct {
HeapAlloc uint64 `json:"HeapAlloc"`
Sys uint64 `json:"Sys"`
HeapObjects uint64 `json:"HeapObjects"`
NumGC uint32 `json:"NumGC"`
PauseNs [256]uint64 `json:"PauseNs"`
} `json:"memstats"`
Observatory map[string]rawObsEntry `json:"observatory"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
return
}
xrayMetrics.append("xrAlloc", t, float64(payload.MemStats.HeapAlloc))
xrayMetrics.append("xrSys", t, float64(payload.MemStats.Sys))
xrayMetrics.append("xrHeapObjects", t, float64(payload.MemStats.HeapObjects))
xrayMetrics.append("xrNumGC", t, float64(payload.MemStats.NumGC))
var lastPause uint64
if payload.MemStats.NumGC > 0 {
idx := (payload.MemStats.NumGC + 255) % 256
lastPause = payload.MemStats.PauseNs[idx]
}
xrayMetrics.append("xrPauseNs", t, float64(lastPause))
s.applyObservatory(t, payload.Observatory)
s.setState(xrayMetricsState{Enabled: true, Listen: listen})
}
func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]rawObsEntry) {
next := make(map[string]ObsTagSnapshot, len(entries))
for key, e := range entries {
tag := e.OutboundTag
if tag == "" {
tag = key
}
if !validObsTag(tag) {
continue
}
snap := ObsTagSnapshot{
Tag: tag,
Alive: e.Alive,
Delay: e.Delay,
LastSeenTime: e.LastSeenTime,
LastTryTime: e.LastTryTime,
UpdatedAt: t.Unix(),
}
next[tag] = snap
xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay))
}
threshold := 3
if v, err := s.settingService.GetOutboundDownThreshold(); err == nil && v > 0 {
threshold = v
}
s.mu.Lock()
// Debounce observatory flapping into stable down/up notifications.
if eventBus != nil {
if s.health == nil {
s.health = make(map[string]outboundHealth, len(next))
}
for tag, cur := range next {
// React only to a genuinely new probe attempt (lastTry advanced).
// The sampler polls far more often than xray probes, so counting
// samples instead of probes would trip the threshold instantly.
h := s.health[tag]
if cur.LastTryTime == 0 || cur.LastTryTime == h.lastTry {
continue
}
h.lastTry = cur.LastTryTime
if cur.Alive {
if h.notified {
eventBus.Publish(eventbus.Event{
Type: eventbus.EventOutboundUp,
Source: tag,
Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
})
}
h.failStreak = 0
h.notified = false
} else {
h.failStreak++
if h.failStreak >= threshold && !h.notified {
errMsg := ""
if cur.Delay < 0 {
errMsg = "probe failed"
}
eventBus.Publish(eventbus.Event{
Type: eventbus.EventOutboundDown,
Source: tag,
Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
})
h.notified = true
}
}
s.health[tag] = h
}
// Forget tags that vanished from the observatory.
for tag := range s.health {
if _, ok := next[tag]; !ok {
delete(s.health, tag)
}
}
}
for tag := range s.obsByTag {
if _, kept := next[tag]; !kept {
xrayMetrics.drop(obsHistoryKey(tag))
}
}
s.obsByTag = next
s.mu.Unlock()
}
func (s *XrayMetricsService) setState(st xrayMetricsState) {
s.mu.Lock()
s.state = st
s.mu.Unlock()
if !st.Enabled && st.Reason != "" {
logger.Debugf("xray metrics unavailable: %s", st.Reason)
}
}
// setStateQuiet updates the metrics state without the Debug log line, for
// states that are permanent by design rather than a transient failure.
func (s *XrayMetricsService) setStateQuiet(st xrayMetricsState) {
s.mu.Lock()
s.state = st
s.mu.Unlock()
}