精简版第四轮补裁(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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user