diff --git a/internal/web/service/panel/panel.go b/internal/web/service/panel/panel.go index c4db7017c..228b4a355 100644 --- a/internal/web/service/panel/panel.go +++ b/internal/web/service/panel/panel.go @@ -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 { diff --git a/internal/web/service/server.go b/internal/web/service/server.go index e5c3a3625..ee0930578 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -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 diff --git a/internal/web/service/server_xray_update_test.go b/internal/web/service/server_xray_update_test.go new file mode 100644 index 000000000..f94e16f05 --- /dev/null +++ b/internal/web/service/server_xray_update_test.go @@ -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) + } + }) + } +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 7712e06c3..27674e56f 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -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 } diff --git a/internal/xray/kernel_tun.go b/internal/xray/kernel_tun.go new file mode 100644 index 000000000..5a23c47e6 --- /dev/null +++ b/internal/xray/kernel_tun.go @@ -0,0 +1,45 @@ +package xray + +import ( + "encoding/json" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" +) + +// ForceUserspaceWireGuardTun sets noKernelTun on every WireGuard outbound and returns the tags +// it changed. Values stay raw so the template's other keys reach the core byte for byte. +func ForceUserspaceWireGuardTun(raw json_util.RawMessage) (json_util.RawMessage, []string) { + var outbounds []map[string]json.RawMessage + if err := json.Unmarshal(raw, &outbounds); err != nil { + return raw, nil + } + var moved []string + for _, ob := range outbounds { + var protocol 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 { + var tag string + _ = json.Unmarshal(ob["tag"], &tag) + moved = append(moved, tag) + } + } + rewritten, err := json.Marshal(outbounds) + if err != nil { + return raw, nil + } + return rewritten, moved +} diff --git a/internal/xray/process.go b/internal/xray/process.go index a9d8c9ce0..4cfae62d3 100644 --- a/internal/xray/process.go +++ b/internal/xray/process.go @@ -595,6 +595,43 @@ func (p *process) refreshAPIPort() { p.mu.Unlock() } +// xrayCheckTimeout bounds `run -test`, which loads the config and its geo files but opens +// no listeners, so it can run while the live core holds the ports. +const xrayCheckTimeout = 30 * time.Second + +// CheckConfig runs the core at binaryPath in test mode against cfg. Updates call it first, +// so a core that rejects cfg never replaces the running one. +func CheckConfig(binaryPath string, cfg *Config) error { + // The test run builds every outbound. A kernel-TUN WireGuard one would add a second wg + // device and IPv6 policy rules to the host, and the run exits without removing the rules. + checked := *cfg + checked.OutboundConfigs, _ = ForceUserspaceWireGuardTun(cfg.OutboundConfigs) + data, err := json.Marshal(&checked) + if err != nil { + return err + } + // CreateTemp creates the file as 0600; the config holds client credentials. + file, err := os.CreateTemp(filepath.Dir(binaryPath), ".config-check-*.json") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, err = file.Write(data) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), xrayCheckTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, binaryPath, "run", "-test", "-c", file.Name()).CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, bytes.TrimSpace(out)) + } + return nil +} + // refreshVersion updates the version string by running the Xray binary with -version. func (p *process) refreshVersion() { version := "Unknown" diff --git a/internal/xray/process_check_test.go b/internal/xray/process_check_test.go new file mode 100644 index 000000000..788e4d0de --- /dev/null +++ b/internal/xray/process_check_test.go @@ -0,0 +1,48 @@ +package xray + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" +) + +// A kernel-TUN WireGuard outbound in `run -test` adds a second wg device and IPv6 policy +// rules to the host, and the check exits without removing the rules. +func TestCheckConfigKeepsWireGuardOffKernelTun(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + dir := t.TempDir() + checked := filepath.Join(dir, "checked.json") + core := filepath.Join(dir, "xray") + script := "#!/bin/sh\n[ \"$1 $2 $3\" = 'run -test -c' ] || exit 99\nexec cp \"$4\" '" + checked + "'\n" + if err := os.WriteFile(core, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + cfg := &Config{OutboundConfigs: json_util.RawMessage( + `[{"protocol":"wireguard","tag":"warp","settings":{"noKernelTun":false,"mtu":1420}}]`)} + if err := CheckConfig(core, cfg); err != nil { + t.Fatalf("CheckConfig: %v", err) + } + + data, err := os.ReadFile(checked) + if err != nil { + t.Fatal(err) + } + var got struct { + Outbounds []struct { + Settings map[string]any `json:"settings"` + } `json:"outbounds"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("checked config: %v\n%s", err, data) + } + if len(got.Outbounds) != 1 || got.Outbounds[0].Settings["noKernelTun"] != true { + t.Fatalf("checked outbounds = %+v, want the wireguard outbound on noKernelTun", got.Outbounds) + } +} diff --git a/update.sh b/update.sh index e35308916..a4377a719 100755 --- a/update.sh +++ b/update.sh @@ -73,6 +73,9 @@ if _command_exists curl; then else _fail "ERROR: Command 'curl' not found." fi +# The panel sets XUI_UPDATE_PROXY to its egress bridge's socks5h URL for the GitHub downloads. +# The IP lookups use plain curl so they still report this server's own address. +[[ -n "${XUI_UPDATE_PROXY}" ]] && curl_bin="${curl_bin} --proxy ${XUI_UPDATE_PROXY}" # Check OS and set release variable if [[ -f /etc/os-release ]]; then @@ -524,17 +527,15 @@ update_x-ui() { fi fi echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..." - # x-ui.sh, x-ui.rc and the unit files must come from the same release as - # the binary; only the rolling dev build tracks main. + # x-ui.rc and the unit files must come from the binary's own release; only the + # rolling dev build tracks main. x-ui.sh ships in the archive and needs no GitHub check. script_ref="${tag_version}" if [[ "${tag_version}" == "dev-latest" ]]; then script_ref="main" fi # The unit files are only fetched when the release tarball lacks them, so # they are checked at that point instead of here. - local required_files=("x-ui.sh") - [[ $release == "alpine" ]] && required_files+=("x-ui.rc") - require_repo_files "${script_ref}" "${required_files[@]}" + [[ $release == "alpine" ]] && require_repo_files "${script_ref}" "x-ui.rc" ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null if [[ $? -ne 0 ]]; then _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub" @@ -566,6 +567,23 @@ update_x-ui() { _fail "ERROR: Failed to download the checksum for x-ui-linux-$(arch).tar.gz (HTTP ${sidecar_code})" fi + # Download everything before x-ui stops, because its Xray and any panel egress bridge + # stop with it. Alpine's init script is the only file the archive lacks. + if [[ $release == "alpine" ]]; then + echo -e "${green}Downloading startup unit x-ui.rc...${plain}" + xui_rc_temp="/etc/init.d/x-ui.tmp.$$" + rm -f "${xui_rc_temp}" + ${curl_bin} -fLRo "${xui_rc_temp}" "https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.rc" > /dev/null 2>&1 + if [[ $? -ne 0 ]]; then + rm -f "${xui_rc_temp}" "${archive}" + _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub" + fi + if [[ ! -s "${xui_rc_temp}" ]]; then + rm -f "${xui_rc_temp}" "${archive}" + _fail "ERROR: Downloaded startup unit x-ui.rc is empty, please be sure that your server can access GitHub" + fi + fi + if [[ -e ${xui_folder}/ ]]; then echo -e "${green}Stopping x-ui...${plain}" if [[ $release == "alpine" ]]; then @@ -650,20 +668,10 @@ update_x-ui() { chmod +x bin/mtg-linux-$(arch) > /dev/null 2>&1 fi - echo -e "${green}Downloading and installing x-ui.sh script...${plain}" + # The archive's x-ui.sh is built from the same commit as the binary. + echo -e "${green}Installing x-ui.sh script...${plain}" local xui_script_temp="/usr/bin/x-ui-temp.$$" - rm -f "${xui_script_temp}" - ${curl_bin} -fLRo "${xui_script_temp}" "https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.sh" > /dev/null 2>&1 - if [[ $? -ne 0 ]]; then - rm -f "${xui_script_temp}" - _fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub" - fi - if [[ ! -s "${xui_script_temp}" ]]; then - rm -f "${xui_script_temp}" - _fail "ERROR: Downloaded x-ui.sh script is empty, please be sure that your server can access GitHub" - fi - mv -f "${xui_script_temp}" /usr/bin/x-ui - if [[ $? -ne 0 ]]; then + if ! cp -f x-ui.sh "${xui_script_temp}" || ! mv -f "${xui_script_temp}" /usr/bin/x-ui; then rm -f "${xui_script_temp}" _fail "ERROR: Failed to install x-ui.sh script" fi @@ -686,18 +694,7 @@ update_x-ui() { "${xui_folder}/x-ui" migrate if [[ $release == "alpine" ]]; then - echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}" - xui_rc_temp="/etc/init.d/x-ui.tmp.$$" - rm -f "${xui_rc_temp}" - ${curl_bin} -fLRo "${xui_rc_temp}" "https://raw.githubusercontent.com/MHSanaei/3x-ui/${script_ref}/x-ui.rc" > /dev/null 2>&1 - if [[ $? -ne 0 ]]; then - rm -f "${xui_rc_temp}" - _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub" - fi - if [[ ! -s "${xui_rc_temp}" ]]; then - rm -f "${xui_rc_temp}" - _fail "ERROR: Downloaded startup unit x-ui.rc is empty, please be sure that your server can access GitHub" - fi + echo -e "${green}Installing startup unit x-ui.rc...${plain}" mv -f "${xui_rc_temp}" /etc/init.d/x-ui if [[ $? -ne 0 ]]; then rm -f "${xui_rc_temp}"