精简版第四轮补裁(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同样失败(既有问题, 非本轮引入)
This commit is contained in:
Muse
2026-10-20 14:58:05 +00:00
parent 945b3a56a2
commit 4828f946b1
56 changed files with 149 additions and 11458 deletions
-489
View File
@@ -1,7 +1,6 @@
package service
import (
"archive/zip"
"bufio"
"bytes"
"cmp"
@@ -161,23 +160,12 @@ type ServerService struct {
lastStatus *Status
versionsCacheMu sync.Mutex
versionsCache *cachedXrayVersions
fail2banMu sync.Mutex
fail2banInstalled bool
fail2banCheckedAt time.Time
}
type cachedXrayVersions struct {
versions []string
fetchedAt time.Time
}
// xrayVersionsCacheTTL bounds how often /getXrayVersion hits GitHub. The list
// is purely informational (rendered in the "switch Xray version" picker) so a
// quarter-hour staleness window is fine and saves the API budget.
const xrayVersionsCacheTTL = 15 * time.Minute
// allowedHistoryBuckets is the bucket-second whitelist for time-series
// aggregation endpoints (server + node metrics). Restricting it prevents
// callers from triggering arbitrary aggregation work and keeps the
@@ -272,31 +260,6 @@ func (s *ServerService) RefreshStatus() *Status {
return next
}
// GetXrayVersionsCached wraps GetXrayVersions with a TTL cache. On fetch
// failure we serve the last successful list (if any) so the UI doesn't go
// blank during a GitHub API hiccup; if there's no cache at all the underlying
// error is surfaced.
func (s *ServerService) GetXrayVersionsCached() ([]string, error) {
s.versionsCacheMu.Lock()
cache := s.versionsCache
s.versionsCacheMu.Unlock()
if cache != nil && time.Since(cache.fetchedAt) <= xrayVersionsCacheTTL {
return cache.versions, nil
}
versions, err := s.GetXrayVersions()
if err != nil {
if cache != nil {
logger.Warning("GetXrayVersionsCached: serving stale list:", err)
return cache.versions, nil
}
return nil, err
}
s.versionsCacheMu.Lock()
s.versionsCache = &cachedXrayVersions{versions: versions, fetchedAt: time.Now()}
s.versionsCacheMu.Unlock()
return versions, nil
}
// GetDefaultLogOutboundTags scans the default Xray config for freedom and
// blackhole outbound tags so /getXrayLogs can colour-code log lines without
// the controller re-doing the JSON walk. Falls back to the historical
@@ -789,75 +752,6 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
return s.emaCPU, nil
}
const (
maxXrayArchiveBytes = 200 << 20
maxXrayBinaryBytes = 200 << 20
// maxXrayDigestBytes caps the .dgst checksum sidecar read; it is a few
// hundred bytes in practice.
maxXrayDigestBytes = 64 << 10
)
func (s *ServerService) GetXrayVersions() ([]string, error) {
const (
XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
bufferSize = 8192
)
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, XrayURL, nil)
if reqErr != nil {
return nil, reqErr
}
resp, err := s.settingService.NewProxiedHTTPClient(10 * time.Second).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check HTTP status code - GitHub API returns object instead of array on error
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
var errorResponse struct {
Message string `json:"message"`
}
if json.Unmarshal(bodyBytes, &errorResponse) == nil && errorResponse.Message != "" {
return nil, fmt.Errorf("GitHub API error: %s", errorResponse.Message)
}
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
}
buffer := bytes.NewBuffer(make([]byte, bufferSize))
buffer.Reset()
if _, err := buffer.ReadFrom(resp.Body); err != nil {
return nil, err
}
var releases []Release
if err := json.Unmarshal(buffer.Bytes(), &releases); err != nil {
return nil, err
}
var versions []string
for _, release := range releases {
tagVersion := strings.TrimPrefix(release.TagName, "v")
tagParts := strings.Split(tagVersion, ".")
if len(tagParts) != 3 {
continue
}
major, err1 := strconv.Atoi(tagParts[0])
minor, err2 := strconv.Atoi(tagParts[1])
patch, err3 := strconv.Atoi(tagParts[2])
if err1 != nil || err2 != nil || err3 != nil {
continue
}
if major > 26 || (major == 26 && minor > 6) || (major == 26 && minor == 6 && patch >= 27) {
versions = append(versions, release.TagName)
}
}
return versions, nil
}
func (s *ServerService) StopXrayService() error {
err := s.xrayService.StopXray()
if err != nil {
@@ -876,240 +770,6 @@ func (s *ServerService) RestartXrayService() error {
return nil
}
func (s *ServerService) downloadXRay(version string) (string, error) {
osName := runtime.GOOS
arch := runtime.GOARCH
switch osName {
case "darwin":
osName = "macos"
case "windows":
osName = "windows"
}
switch arch {
case "amd64":
arch = "64"
case "arm64":
arch = "arm64-v8a"
case "armv7":
arch = "arm32-v7a"
case "armv6":
arch = "arm32-v6"
case "armv5":
arch = "arm32-v5"
case "386":
arch = "32"
case "s390x":
arch = "s390x"
}
fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
client := s.settingService.NewProxiedHTTPClient(60 * time.Second)
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if reqErr != nil {
return "", reqErr
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download xray: unexpected HTTP %d", resp.StatusCode)
}
if resp.ContentLength > maxXrayArchiveBytes {
return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
}
file, err := os.CreateTemp("", "xray-*.zip")
if err != nil {
return "", err
}
path := file.Name()
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(path)
}
}()
n, err := io.Copy(file, io.LimitReader(resp.Body, maxXrayArchiveBytes+1))
if err != nil {
return "", err
}
if n > maxXrayArchiveBytes {
return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
}
// Verify the archive against the SHA2-256 published in the release's .dgst
// sidecar before installing it. TLS protects the transport, not the artifact;
// a corrupted or tampered asset must not be installed and run as xray.
want, err := s.fetchXrayDigestSHA256(client, url+".dgst")
if err != nil {
return "", err
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
if got := hex.EncodeToString(hasher.Sum(nil)); !strings.EqualFold(got, want) {
// User-facing warning: the archive's SHA-256 does not match the official
// release checksum, so the download is corrupted or has been tampered
// with. Abort the install so a bad binary is never run, and tell the user
// to retry/re-download rather than proceed with a mismatched image.
return "", fmt.Errorf("Xray update aborted: the downloaded archive does not match the official SHA-256 checksum, so the image is corrupted or differs from the official release. Please exit and re-download the official image, then try again (expected %s, got %s)", want, got)
}
ok = true
return path, nil
}
// fetchXrayDigestSHA256 downloads the .dgst sidecar XTLS publishes next to each
// release asset and returns the SHA2-256 hex digest it lists.
func (s *ServerService) fetchXrayDigestSHA256(client *http.Client, dgstURL string) (string, error) {
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, dgstURL, nil)
if reqErr != nil {
return "", fmt.Errorf("download xray checksum: %w", reqErr)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("download xray checksum: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download xray checksum: unexpected HTTP %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxXrayDigestBytes))
if err != nil {
return "", fmt.Errorf("download xray checksum: %w", err)
}
return parseXrayDigestSHA256(raw)
}
// parseXrayDigestSHA256 extracts the lowercase SHA2-256 hex from an XTLS .dgst
// file, whose lines are "ALGO= <hex>" (the relevant one being "SHA2-256= ...").
func parseXrayDigestSHA256(dgst []byte) (string, error) {
for line := range strings.SplitSeq(string(dgst), "\n") {
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "SHA2-256=")
if !ok {
continue
}
h := strings.ToLower(strings.TrimSpace(rest))
if len(h) != 64 {
return "", fmt.Errorf("xray checksum: malformed SHA2-256 entry in digest")
}
return h, nil
}
return "", fmt.Errorf("xray checksum: no SHA2-256 entry in digest")
}
func (s *ServerService) UpdateXray(version string) error {
versions, err := s.GetXrayVersions()
if err != nil {
return err
}
if !slices.Contains(versions, version) {
return fmt.Errorf("xray version %q is not in the fetched release list", version)
}
// 1. Stop xray before doing anything
if err := s.StopXrayService(); err != nil {
logger.Warning("failed to stop xray before update:", err)
}
// 2. Download the zip
zipFileName, err := s.downloadXRay(version)
if err != nil {
return err
}
defer os.Remove(zipFileName)
zipFile, err := os.Open(zipFileName)
if err != nil {
return err
}
defer zipFile.Close()
stat, err := zipFile.Stat()
if err != nil {
return err
}
reader, err := zip.NewReader(zipFile, stat.Size())
if err != nil {
return err
}
// 3. Helper to extract files
copyZipFile := func(zipName string, fileName string) error {
zipFile, err := reader.Open(zipName)
if err != nil {
return err
}
defer zipFile.Close()
if err := os.MkdirAll(filepath.Dir(fileName), 0o755); err != nil {
return err
}
tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
ok := false
defer func() {
_ = tmpFile.Close()
if !ok {
_ = os.Remove(tmpPath)
}
}()
n, err := io.Copy(tmpFile, io.LimitReader(zipFile, maxXrayBinaryBytes+1))
if err != nil {
return err
}
if n > maxXrayBinaryBytes {
return fmt.Errorf("xray binary exceeds %d bytes", maxXrayBinaryBytes)
}
if err := tmpFile.Chmod(0o755); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if runtime.GOOS == "windows" {
_ = os.Remove(fileName)
}
if err := os.Rename(tmpPath, fileName); err != nil {
return err
}
ok = true
return nil
}
// 4. Extract correct binary
if runtime.GOOS == "windows" {
targetBinary := filepath.Join(config.GetBinFolderPath(), "xray-windows-amd64.exe")
err = copyZipFile("xray.exe", targetBinary)
} else {
err = copyZipFile("xray", xray.GetBinaryPath())
}
if err != nil {
return err
}
// 5. Restart xray
if err := s.xrayService.RestartXray(true); err != nil {
logger.Error("start xray failed:", err)
return err
}
return nil
}
func (s *ServerService) GetLogs(count string, level string, syslog string) []string {
c, _ := strconv.Atoi(count)
var lines []string
@@ -2141,155 +1801,6 @@ func stageSQLiteUpload(file multipart.File, kind int, tempPath string) error {
return nil
}
// IsValidGeofileName validates that the filename is safe for geofile operations.
// It checks for path traversal attempts and ensures the filename contains only safe characters.
func (s *ServerService) IsValidGeofileName(filename string) bool {
if filename == "" {
return false
}
// Check for path traversal attempts
if strings.Contains(filename, "..") {
return false
}
// Check for path separators (both forward and backward slash)
if strings.ContainsAny(filename, `/\`) {
return false
}
// Check for absolute path indicators
if filepath.IsAbs(filename) {
return false
}
// Additional security: only allow alphanumeric, dots, underscores, and hyphens
// This is stricter than the general filename regex
validGeofilePattern := `^[a-zA-Z0-9._-]+\.dat$`
matched, _ := regexp.MatchString(validGeofilePattern, filename)
return matched
}
func (s *ServerService) UpdateGeofile(fileName string) error {
type geofileEntry struct {
URL string
FileName string
}
geofileAllowlist := map[string]geofileEntry{
"geoip.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
"geosite.dat": {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
"geoip_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
"geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
"geoip_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
}
// Strict allowlist check to avoid writing uncontrolled files
if fileName != "" {
if _, ok := geofileAllowlist[fileName]; !ok {
return common.NewErrorf("Invalid geofile name: %q not in allowlist", fileName)
}
}
client := s.settingService.NewProxiedHTTPClient(0)
downloadFile := func(url, destPath string) error {
var req *http.Request
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return common.NewErrorf("Failed to create HTTP request for %s: %v", url, err)
}
var localFileModTime time.Time
if fileInfo, err := os.Stat(destPath); err == nil {
localFileModTime = fileInfo.ModTime()
if !localFileModTime.IsZero() {
req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
}
}
resp, err := client.Do(req)
if err != nil {
return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
}
defer resp.Body.Close()
// Parse Last-Modified header from server
var serverModTime time.Time
serverModTimeStr := resp.Header.Get("Last-Modified")
if serverModTimeStr != "" {
parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
if err != nil {
logger.Warningf("Failed to parse Last-Modified header for %s: %v", url, err)
} else {
serverModTime = parsedTime
}
}
// Function to update local file's modification time
updateFileModTime := func() {
if !serverModTime.IsZero() {
if err := os.Chtimes(destPath, serverModTime, serverModTime); err != nil {
logger.Warningf("Failed to update modification time for %s: %v", destPath, err)
}
}
}
// Handle 304 Not Modified
if resp.StatusCode == http.StatusNotModified {
updateFileModTime()
return nil
}
if resp.StatusCode != http.StatusOK {
return common.NewErrorf("Failed to download Geofile from %s: received status code %d", url, resp.StatusCode)
}
file, err := os.Create(destPath)
if err != nil {
return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err)
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err)
}
updateFileModTime()
return nil
}
var errorMessages []string
if fileName == "" {
// Download all geofiles
for _, entry := range geofileAllowlist {
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
if err := downloadFile(entry.URL, destPath); err != nil {
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
}
}
} else {
entry := geofileAllowlist[fileName]
destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
if err := downloadFile(entry.URL, destPath); err != nil {
errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
}
}
err := s.RestartXrayService()
if err != nil {
errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofile '%s' but Failed to start Xray: %v", fileName, err))
}
if len(errorMessages) > 0 {
return common.NewErrorf("%s", strings.Join(errorMessages, "\r\n"))
}
return nil
}
// parseXrayKeyPairOutput reads the two-line "Label: value" output that xray's
// key-generation subcommands (x25519, mldsa65, mlkem768) print and returns the
// two values. Short or label-less output yields an error instead of panicking
@@ -1,72 +0,0 @@
package service
import (
"net/http"
"net/http/httptest"
"testing"
)
// A real XTLS .dgst sidecar (Xray-linux-64.zip.dgst, v26.3.27): lines are
// "ALGO= <hex>", and the algorithm label is "SHA2-256", not "SHA256".
const sampleXrayDgst = `# Hash Values
MD5= ee4e2ff74948a9b464624b1cabc44409
SHA1= b55b06e74e89083b9cedfdecf0d68b579cd2af72
SHA2-256= 23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae
SHA2-512= e8bc40a0687cac184bbe4b5c1f047e69064ccedc489fb25e208889ae287bbf8736dff16b108d68fc00dc33edc8bb53502e47a9698a277f4f51b67b83d899e518
`
const wantSHA = "23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae"
func TestParseXrayDigestSHA256(t *testing.T) {
got, err := parseXrayDigestSHA256([]byte(sampleXrayDgst))
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != wantSHA {
t.Fatalf("sha = %q, want %q", got, wantSHA)
}
}
func TestParseXrayDigestSHA256_Errors(t *testing.T) {
for _, tc := range []struct {
name string
in string
}{
{"no-sha256-line", "MD5= abc\nSHA1= def\n"},
{"malformed-short", "SHA2-256= deadbeef\n"},
{"empty", ""},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := parseXrayDigestSHA256([]byte(tc.in)); err == nil {
t.Fatalf("%s: expected an error", tc.name)
}
})
}
}
func TestFetchXrayDigestSHA256(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(sampleXrayDgst))
}))
defer srv.Close()
got, err := (&ServerService{}).fetchXrayDigestSHA256(srv.Client(), srv.URL+"/Xray-linux-64.zip.dgst")
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got != wantSHA {
t.Fatalf("sha = %q, want %q", got, wantSHA)
}
}
func TestFetchXrayDigestSHA256_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusNotFound)
}))
defer srv.Close()
if _, err := (&ServerService{}).fetchXrayDigestSHA256(srv.Client(), srv.URL+"/missing.dgst"); err == nil {
t.Fatal("expected an error on HTTP 404")
}
}
+42 -11
View File
@@ -26,8 +26,27 @@ var (
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
xrayState xrayLifecycle
// localCoreDisabledOnce ensures the "local core disabled" notice is
// logged exactly once per panel run, no matter how many legacy callers
// still ask for a local restart.
localCoreDisabledOnce sync.Once
)
// logLocalCoreDisabled records, once, that local-xray lifecycle requests are
// being ignored. The lite master is a pure control plane: it never runs a
// local xray process (all inbounds live on agent nodes), so restarts are
// no-ops instead of spawn attempts that would fail every few seconds on
// hosts where bin/xray is absent or deliberately disabled.
func logLocalCoreDisabled() {
localCoreDisabledOnce.Do(func() {
// Warning level on purpose: the panel's default log level hides
// Info, and this one-time notice is the only sign that legacy
// restart requests are being absorbed.
logger.Warning("local xray core is disabled in this build (control-plane only); local restart requests are ignored")
})
}
type xrayLifecycle struct {
mu sync.RWMutex
process *xray.Process
@@ -1421,6 +1440,18 @@ func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResul
// routing rules/balancers are hot-reloadable); only changes the core cannot
// take at runtime — or a force request — stop and restart the process.
func (s *XrayService) RestartXray(isForce bool) error {
// Lite control-plane build: the local xray process is never started.
// Every historical caller (startup, crash watchdog, pending-restart
// ticker, settings-save hooks, server endpoints) funnels through here,
// so degrading this single point retires the whole local lifecycle
// without touching agent-node flows (the agent runs its own core via
// internal/agent, not via this service).
_ = isForce
logLocalCoreDisabled()
return nil
}
func (s *XrayService) restartXrayLegacy(isForce bool) error {
lock.Lock()
defer lock.Unlock()
logger.Debug("restart Xray, force:", isForce)
@@ -1600,7 +1631,10 @@ func (s *XrayService) StopXray() error {
if process != nil && process.IsRunning() {
return process.Stop()
}
return errors.New("xray is not running")
// Lite control-plane build: no local process ever runs, so stopping is
// an idempotent no-op rather than an error.
logLocalCoreDisabled()
return nil
}
// SetToNeedRestart marks that Xray needs to be restarted.
@@ -1625,17 +1659,14 @@ func (s *XrayService) IsNeedRestartAndSetFalse() bool {
return isNeedXrayRestart.CompareAndSwap(true, false)
}
// ApplyPendingRestart consumes the need-restart flag and restarts Xray. If the
// restart fails (for example GetXrayConfig hits a transient DB error and leaves
// the old process running), it re-arms the flag so the next tick retries instead
// of silently dropping the pending config change.
// ApplyPendingRestart used to consume the need-restart flag and restart
// the local core, re-arming the flag on failure so the next tick retried.
// The local core never runs in this build, so a pending restart is simply
// dropped: clear the flag and do nothing. (Its only caller, the 30s cron
// tick in web.go, has been removed as well.)
func (s *XrayService) ApplyPendingRestart() {
if !s.IsNeedRestartAndSetFalse() {
return
}
if err := s.RestartXray(false); err != nil {
logger.Error("restart xray failed:", err)
s.SetToNeedRestart()
if s.IsNeedRestartAndSetFalse() {
logLocalCoreDisabled()
}
}
+16
View File
@@ -159,6 +159,14 @@ type rawObsEntry struct {
}
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()})
@@ -312,3 +320,11 @@ func (s *XrayMetricsService) setState(st xrayMetricsState) {
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()
}
+6 -6
View File
@@ -19,11 +19,11 @@ func TestRestartXrayRespectsManualStop(t *testing.T) {
}
}
func TestApplyPendingRestartReArmsFlagOnFailure(t *testing.T) {
// The local core never runs in this build, so ApplyPendingRestart no
// longer restarts anything and must not re-arm the flag for a retry loop:
// it consumes the pending flag and drops it.
func TestApplyPendingRestartDropsFlagWithoutLocalCore(t *testing.T) {
setupSettingTestDB(t)
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
t.Fatalf("seed template: %v", err)
}
t.Cleanup(func() {
isManuallyStopped.Store(false)
isNeedXrayRestart.Store(false)
@@ -34,7 +34,7 @@ func TestApplyPendingRestartReArmsFlagOnFailure(t *testing.T) {
svc.SetToNeedRestart()
svc.ApplyPendingRestart()
if !isNeedXrayRestart.Load() {
t.Fatal("a failed restart must re-arm the need-restart flag so the pending config change is retried")
if isNeedXrayRestart.Load() {
t.Fatal("pending restart must be consumed and dropped; there is no local core to retry against")
}
}