fix(update): keep Xray-core running until update downloads finish (#7002)

UpdateXray stopped the core before downloading, which also stopped the Panel Traffic Outbound bridge the download needed on servers that reach GitHub only through it, leaving every client offline. The new core is now downloaded, staged and checked with run -test against the current config while the old one serves; only the rename and restart swap it (Windows still stops first). update.sh gets the bridge as XUI_UPDATE_PROXY, installs x-ui.sh from the release archive, and fetches Alpine's x-ui.rc before stopping x-ui.
This commit is contained in:
Cheng Ho Ming, Eric
2026-11-02 22:17:01 +08:00
committed by GitHub
parent 4d583cebf3
commit 6d9e836d61
8 changed files with 422 additions and 97 deletions
+5
View File
@@ -250,6 +250,9 @@ func (s *PanelService) startUpdate(useDev bool) (int64, error) {
updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
runIDEnv := "XUI_UPDATE_RUN_ID=" + strconv.FormatInt(runID, 10)
statusFileEnv := "XUI_UPDATE_STATUS_FILE=" + statusFile
// update.sh sends its curl downloads through the panel egress bridge. With socks5h the
// proxy resolves GitHub's hostnames, since this server's own DNS may be filtered.
egressEnv := "XUI_UPDATE_PROXY=" + strings.Replace((&service.SettingService{}).PanelEgressProxyURL(), "socks5://", "socks5h://", 1)
proxyEnv := updateProxyEnvVars()
if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
@@ -261,6 +264,7 @@ func (s *PanelService) startUpdate(useDev bool) (int64, error) {
"--setenv", "XUI_UPDATE_TAG=" + updateTag,
"--setenv", runIDEnv,
"--setenv", statusFileEnv,
"--setenv", egressEnv,
}
for _, kv := range proxyEnv {
args = append(args, "--setenv", kv)
@@ -290,6 +294,7 @@ func (s *PanelService) startUpdate(useDev bool) (int64, error) {
"XUI_UPDATE_TAG="+updateTag,
runIDEnv,
statusFileEnv,
egressEnv,
)
setDetachedProcess(cmd)
if err := cmd.Start(); err != nil {
+48 -37
View File
@@ -933,13 +933,16 @@ const (
maxXrayReleasesBytes = 32 << 20
)
func (s *ServerService) GetXrayVersions() ([]string, error) {
const (
XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
bufferSize = 8192
)
// Tests point these at a local server that stands in for GitHub.
var (
xrayReleasesURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
xrayDownloadURL = "https://github.com/XTLS/Xray-core/releases/download"
)
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, XrayURL, nil)
func (s *ServerService) GetXrayVersions() ([]string, error) {
const bufferSize = 8192
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, xrayReleasesURL, nil)
if reqErr != nil {
return nil, reqErr
}
@@ -1041,7 +1044,7 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
}
fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
url := fmt.Sprintf("%s/%s/%s", xrayDownloadURL, version, fileName)
client := s.settingService.NewProxiedHTTPClient(60 * time.Second)
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if reqErr != nil {
@@ -1154,12 +1157,8 @@ func (s *ServerService) UpdateXray(version string) error {
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
// Keep the running core up until the swap. The download may need its panel egress
// bridge, and if the candidate fails the config check, clients stay on the old core.
zipFileName, err := s.downloadXRay(version)
if err != nil {
return err
@@ -1181,19 +1180,20 @@ func (s *ServerService) UpdateXray(version string) error {
return err
}
// 3. Helper to extract files
copyZipFile := func(zipName string, fileName string) error {
// Stage next to the target so the swap is an atomic rename on one filesystem, and
// keep the target's extension so Windows will run the staged file for the check.
stageZipFile := func(zipName string, fileName string) (string, error) {
zipFile, err := reader.Open(zipName)
if err != nil {
return err
return "", err
}
defer zipFile.Close()
if err := os.MkdirAll(filepath.Dir(fileName), 0o755); err != nil {
return err
return "", err
}
tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*")
tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*"+filepath.Ext(fileName))
if err != nil {
return err
return "", err
}
tmpPath := tmpFile.Name()
ok := false
@@ -1205,42 +1205,53 @@ func (s *ServerService) UpdateXray(version string) error {
}()
n, err := io.Copy(tmpFile, io.LimitReader(zipFile, maxXrayBinaryBytes+1))
if err != nil {
return err
return "", err
}
if n > maxXrayBinaryBytes {
return fmt.Errorf("xray binary exceeds %d bytes", maxXrayBinaryBytes)
return "", fmt.Errorf("xray binary exceeds %d bytes", maxXrayBinaryBytes)
}
if err := tmpFile.Chmod(0o755); err != nil {
return err
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
return "", err
}
ok = true
return nil
return tmpPath, nil
}
// 4. Extract correct binary
zipName, target := "xray", xray.GetBinaryPath()
if runtime.GOOS == "windows" {
targetBinary := filepath.Join(config.GetBinFolderPath(), "xray-windows-amd64.exe")
err = copyZipFile("xray.exe", targetBinary)
} else {
err = copyZipFile("xray", xray.GetBinaryPath())
zipName, target = "xray.exe", filepath.Join(config.GetBinFolderPath(), "xray-windows-amd64.exe")
}
staged, err := stageZipFile(zipName, target)
if err != nil {
return err
}
defer os.Remove(staged)
xrayConfig, err := s.xrayService.GetXrayConfig()
if err != nil {
return err
}
if err := xray.CheckConfig(staged, xrayConfig); err != nil {
return fmt.Errorf("xray %s failed the config check and was not installed: %w", version, err)
}
if runtime.GOOS == "windows" {
// Windows cannot replace a running executable.
if err := s.StopXrayService(); err != nil {
logger.Warning("failed to stop xray before update:", err)
}
_ = os.Remove(target)
}
if err := os.Rename(staged, target); err != nil {
return err
}
// 5. Restart xray
if err := s.xrayService.RestartXray(true); err != nil {
logger.Error("start xray failed:", err)
return err
return fmt.Errorf("xray %s is installed, but the restart failed: %w", version, err)
}
return nil
@@ -0,0 +1,207 @@
package service
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// fakeXrayCore handles only the command lines the panel uses to run a core. It exits 99
// on any other, so a changed invocation fails the test.
func fakeXrayCore(version string, acceptsConfig bool) string {
check := "exit 0"
if !acceptsConfig {
check = "echo 'Failed to start: fake core refuses this config'; exit 23"
}
return "#!/bin/sh\n" +
"case \"$*\" in\n" +
"-version) echo 'Xray " + version + " (fake)' ;;\n" +
"'run -test -c '*) [ -s \"$4\" ] || exit 98; " + check + " ;;\n" +
"'-c '*) exec sleep 60 ;;\n" +
"*) exit 99 ;;\n" +
"esac\n"
}
func xrayReleaseZip(t *testing.T, core string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("xray")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(core)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// xrayReleaseServer stands in for GitHub. It lists one version and serves asset with a
// matching .dgst, or a 404 when asset is nil.
func xrayReleaseServer(t *testing.T, version string, asset []byte) {
t.Helper()
sum := sha256.Sum256(asset)
mux := http.NewServeMux()
mux.HandleFunc("/releases", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `[{"tag_name":%q}]`, version)
})
mux.HandleFunc("/download/"+version+"/", func(w http.ResponseWriter, r *http.Request) {
switch {
case asset == nil:
http.NotFound(w, r)
case strings.HasSuffix(r.URL.Path, ".zip.dgst"):
fmt.Fprintf(w, "SHA2-256= %s\n", hex.EncodeToString(sum[:]))
case strings.HasSuffix(r.URL.Path, ".zip"):
_, _ = w.Write(asset)
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
releases, download := xrayReleasesURL, xrayDownloadURL
xrayReleasesURL, xrayDownloadURL = srv.URL+"/releases", srv.URL+"/download"
t.Cleanup(func() { xrayReleasesURL, xrayDownloadURL = releases, download })
}
// startFakeCore runs the installed core on the panel's own config, as RestartXray
// would, and stops whichever core is current when the test ends.
func startFakeCore(t *testing.T) *xray.Process {
t.Helper()
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
p := xray.NewProcess(cfg)
if err := p.Start(); err != nil {
t.Fatalf("start fake core: %v", err)
}
restore := SetXrayProcessForTest(p)
t.Cleanup(func() {
if current := XrayProcess(); current != nil {
_ = current.Stop()
}
_ = p.Stop()
restore()
// restore() keeps the hold-back reason that a refused restart sets.
xrayState.holdBack("")
})
return p
}
func TestUpdateXray(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the fake cores are shell scripts")
}
const current, next = "26.6.27", "26.7.1"
installed := fakeXrayCore(current, true)
for _, tc := range []struct {
name string
candidate string // the release's core; empty makes the download 404
conflict bool // two inbounds on one port, added after the old core starts
wantErr string
wantBinary string
wantVersion string
}{
{
// The running core hosts the panel egress bridge. Stopping it first made the
// download bypass the bridge, and when that failed every client stayed offline.
name: "failed download leaves the running core alone",
wantErr: "unexpected HTTP 404",
wantBinary: installed,
wantVersion: current,
},
{
name: "core that refuses the config is not installed",
candidate: fakeXrayCore(next, false),
wantErr: "fake core refuses this config",
wantBinary: installed,
wantVersion: current,
},
{
name: "accepted core replaces the running one",
candidate: fakeXrayCore(next, true),
wantBinary: fakeXrayCore(next, true),
wantVersion: next,
},
{
// A refused restart keeps the old core running, but the new binary is
// already on disk, so the error has to say the update is installed.
name: "refused restart reports the installed core",
candidate: fakeXrayCore(next, true),
conflict: true,
wantErr: "is installed, but the restart failed: xray config refused",
wantBinary: fakeXrayCore(next, true),
wantVersion: current,
},
} {
t.Run(tc.name, func(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
binFolder := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", binFolder)
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
binary := filepath.Join(binFolder, xray.GetBinaryName())
if err := os.WriteFile(binary, []byte(installed), 0o755); err != nil {
t.Fatal(err)
}
running := startFakeCore(t)
if tc.conflict {
for _, tag := range []string{"first", "second"} {
seedInboundConflict(t, tag, "0.0.0.0", 4443, model.VLESS, `{"network":"tcp"}`, `{}`)
}
}
var asset []byte
if tc.candidate != "" {
asset = xrayReleaseZip(t, tc.candidate)
}
xrayReleaseServer(t, "v"+next, asset)
err := (&ServerService{}).UpdateXray("v" + next)
if tc.wantErr == "" && err != nil {
t.Fatalf("UpdateXray: %v", err)
}
if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
t.Fatalf("UpdateXray error = %v, want it to contain %q", err, tc.wantErr)
}
if tc.wantErr != "" && (XrayProcess() != running || !running.IsRunning()) {
t.Fatal("a failed update stopped or replaced the running core")
}
if got := XrayProcess().GetXrayVersion(); got != tc.wantVersion {
t.Fatalf("running core version = %q, want %q", got, tc.wantVersion)
}
got, err := os.ReadFile(binary)
if err != nil {
t.Fatal(err)
}
if string(got) != tc.wantBinary {
t.Fatalf("installed core:\n%s\nwant:\n%s", got, tc.wantBinary)
}
leftovers, err := filepath.Glob(filepath.Join(binFolder, ".*"))
if err != nil || len(leftovers) > 0 {
t.Fatalf("temp files left in the bin folder: %v (glob err %v)", leftovers, err)
}
})
}
}
+5 -30
View File
@@ -2003,37 +2003,12 @@ func normalizeStreamUDPHop(stream map[string]any) bool {
return maskcompat.NormalizeUDPHop(stream["finalmask"])
}
// forceUserspaceWireGuardTun moves every WireGuard outbound to the gVisor TUN (#6796). Values
// stay raw so the template's other keys reach the core byte for byte.
// forceUserspaceWireGuardTun moves every WireGuard outbound to the gVisor TUN (#6796) and
// warns for each one it moves off a kernel TUN.
func forceUserspaceWireGuardTun(raw json_util.RawMessage) json_util.RawMessage {
var outbounds []map[string]json.RawMessage
if err := json.Unmarshal(raw, &outbounds); err != nil {
return raw
}
for _, ob := range outbounds {
var protocol, tag string
if json.Unmarshal(ob["protocol"], &protocol) != nil || !strings.EqualFold(protocol, "wireguard") {
continue
}
var settings map[string]json.RawMessage
if json.Unmarshal(ob["settings"], &settings) != nil || settings == nil {
continue
}
wasUserspace := string(settings["noKernelTun"]) == "true"
settings["noKernelTun"] = json.RawMessage("true")
rewritten, err := json.Marshal(settings)
if err != nil {
continue
}
ob["settings"] = rewritten
if !wasUserspace {
_ = json.Unmarshal(ob["tag"], &tag)
logger.Warningf("Outbound %q: kernel TUN cannot be set up here, running WireGuard on the userspace TUN instead (#6796)", tag)
}
}
rewritten, err := json.Marshal(outbounds)
if err != nil {
return raw
rewritten, moved := xray.ForceUserspaceWireGuardTun(raw)
for _, tag := range moved {
logger.Warningf("Outbound %q: kernel TUN cannot be set up here, running WireGuard on the userspace TUN instead (#6796)", tag)
}
return rewritten
}