diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index dbc78939e..8f86332f9 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2147,6 +2147,34 @@ ], "type": "object" }, + "PanelUpdateStatus": { + "description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.", + "properties": { + "exitCode": { + "example": 0, + "type": "integer" + }, + "finishedAt": { + "example": 1735689612, + "type": "integer" + }, + "runId": { + "example": "1735689600123456789", + "type": "string" + }, + "state": { + "example": "success", + "type": "string" + } + }, + "required": [ + "exitCode", + "finishedAt", + "runId", + "state" + ], + "type": "object" + }, "ProbeResultUI": { "properties": { "cpuPct": { @@ -3901,6 +3929,47 @@ } } }, + "/panel/api/server/getUpdateStatus": { + "get": { + "tags": [ + "Server" + ], + "summary": "Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.", + "operationId": "get_panel_api_server_getUpdateStatus", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/PanelUpdateStatus" + } + } + }, + "example": { + "success": true, + "obj": { + "exitCode": 0, + "finishedAt": 1735689612, + "runId": "1735689600123456789", + "state": "success" + } + } + } + } + } + } + } + }, "/panel/api/server/getConfigJson": { "get": { "tags": [ @@ -4427,6 +4496,12 @@ }, "obj": {} } + }, + "example": { + "success": true, + "obj": { + "runId": "1735689600123456789" + } } } } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 3cc91538d..ff2fdfbdb 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -468,6 +468,12 @@ export const EXAMPLES: Record = { "total": 0, "up": 0 }, + "PanelUpdateStatus": { + "exitCode": 0, + "finishedAt": 1735689612, + "runId": "1735689600123456789", + "state": "success" + }, "ProbeResultUI": { "cpuPct": 12.5, "error": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 96b7d875f..405879ff4 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2121,6 +2121,34 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "PanelUpdateStatus": { + "description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.", + "properties": { + "exitCode": { + "example": 0, + "type": "integer" + }, + "finishedAt": { + "example": 1735689612, + "type": "integer" + }, + "runId": { + "example": "1735689600123456789", + "type": "string" + }, + "state": { + "example": "success", + "type": "string" + } + }, + "required": [ + "exitCode", + "finishedAt", + "runId", + "state" + ], + "type": "object" + }, "ProbeResultUI": { "properties": { "cpuPct": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 110924f13..a5dc724ea 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -464,6 +464,13 @@ export interface OutboundTraffics { up: number; } +export interface PanelUpdateStatus { + exitCode: number; + finishedAt: number; + runId: string; + state: string; +} + export interface ProbeResultUI { cpuPct: number; error: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 74a25af5a..d78e2c4fe 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -495,6 +495,14 @@ export const OutboundTrafficsSchema = z.object({ }); export type OutboundTraffics = z.infer; +export const PanelUpdateStatusSchema = z.object({ + exitCode: z.number().int(), + finishedAt: z.number().int(), + runId: z.string(), + state: z.string(), +}); +export type PanelUpdateStatus = z.infer; + export const ProbeResultUISchema = z.object({ cpuPct: z.number(), error: z.string(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index af2081a4d..4aded9cb2 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -323,6 +323,12 @@ export const sections: readonly Section[] = [ path: '/panel/api/server/getPanelUpdateInfo', summary: 'Check whether a newer 3x-ui release is available on GitHub.', }, + { + method: 'GET', + path: '/panel/api/server/getUpdateStatus', + summary: 'Report the outcome of the most recently launched panel self-update (see POST updatePanel). Compare the returned runId against the one updatePanel returned to tell this run apart from a stale result.', + responseSchema: 'PanelUpdateStatus', + }, { method: 'GET', path: '/panel/api/server/getConfigJson', @@ -407,6 +413,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/server/updatePanel', summary: 'Self-update the panel to the latest version. The server restarts on success.', + response: '{\n "success": true,\n "obj": {\n "runId": "1735689600123456789"\n }\n}', }, { method: 'POST', diff --git a/frontend/src/pages/index/PanelUpdateModal.tsx b/frontend/src/pages/index/PanelUpdateModal.tsx index 0e8bb788b..83dac3b92 100644 --- a/frontend/src/pages/index/PanelUpdateModal.tsx +++ b/frontend/src/pages/index/PanelUpdateModal.tsx @@ -6,8 +6,11 @@ import axios from 'axios'; import { HttpUtil, PromiseUtil } from '@/utils'; import { formatPanelVersion } from '@/lib/panel-version'; +import type { PanelUpdateStatus } from '@/generated/types'; import './PanelUpdateModal.css'; +type UpdateOutcome = 'success' | 'failed' | 'timeout'; + export interface PanelUpdateInfo { channel?: string; currentVersion: string; @@ -45,19 +48,23 @@ export default function PanelUpdateModal({ const isDev = info.channel === 'dev'; - async function pollUntilBack(): Promise { + async function pollUpdateStatus(expectedRunId: string): Promise { await PromiseUtil.sleep(5000); const deadline = Date.now() + 90_000; while (Date.now() < deadline) { try { - const r = await axios.get('/panel/api/server/status', { timeout: 2000 }); - if (r?.data?.success) return true; + const r = await axios.get('/panel/api/server/getUpdateStatus', { timeout: 2000 }); + const status = r?.data?.obj as PanelUpdateStatus | undefined; + if (status?.runId === expectedRunId) { + if (status.state === 'success') return 'success'; + if (status.state === 'failed') return 'failed'; + } } catch { /* still restarting */ } await PromiseUtil.sleep(2000); } - return false; + return 'timeout'; } async function handleChannel(checked: boolean) { @@ -81,14 +88,24 @@ export default function PanelUpdateModal({ const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip; onClose(); onBusy({ busy: true, tip }); - const result = await HttpUtil.post('/panel/api/server/updatePanel'); + const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel'); if (!result?.success) { onBusy({ busy: false }); return; } - const back = await pollUntilBack(); - if (back) await PromiseUtil.sleep(800); - window.location.reload(); + const outcome = await pollUpdateStatus(result.obj?.runId ?? ''); + onBusy({ busy: false }); + if (outcome === 'success') { + await PromiseUtil.sleep(800); + window.location.reload(); + return; + } + modal[outcome === 'failed' ? 'error' : 'warning']({ + title: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedTitle' : 'pages.index.panelUpdateUnknownTitle'), + content: t(outcome === 'failed' ? 'pages.index.panelUpdateFailedDesc' : 'pages.index.panelUpdateUnknownDesc'), + okText: t('refresh'), + onOk: () => window.location.reload(), + }); }, }); } diff --git a/install.sh b/install.sh index 8e28900b4..6156678d8 100644 --- a/install.sh +++ b/install.sh @@ -1326,6 +1326,40 @@ setup_fail2ban() { return 0 } +# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file + +# atomic mv, so a failed cp/curl or an interrupted mv never leaves a +# truncated unit file at the live path -- systemd would then fail to parse +# it on the next daemon-reload/start. Same pattern already used for +# /usr/bin/x-ui elsewhere in this script. source_is_url picks cp (from a +# file already extracted from the release tarball) vs curl (GitHub fallback). +_install_xui_service_unit() { + local source="$1" + local source_is_url="$2" + local dest="${xui_service}/x-ui.service" + local temp_file="${dest}.tmp.$$" + + rm -f "$temp_file" + if [[ "$source_is_url" == "true" ]]; then + curl -fLRo "$temp_file" "$source" > /dev/null 2>&1 + else + cp -f "$source" "$temp_file" > /dev/null 2>&1 + fi + if [[ $? -ne 0 ]]; then + rm -f "$temp_file" + return 1 + fi + if [[ ! -s "$temp_file" ]]; then + rm -f "$temp_file" + return 1 + fi + mv -f "$temp_file" "$dest" + if [[ $? -ne 0 ]]; then + rm -f "$temp_file" + return 1 + fi + return 0 +} + install_x-ui() { cd ${xui_folder%/x-ui}/ @@ -1342,6 +1376,11 @@ install_x-ui() { echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}" exit 1 fi + if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then + rm ${xui_folder}-linux-$(arch).tar.gz -f + echo -e "${red}Downloaded x-ui release archive is empty${plain}" + exit 1 + fi else tag_version=$1 # The rolling dev channel ships under a fixed, non-semver tag that is @@ -1367,12 +1406,25 @@ install_x-ui() { echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}" exit 1 fi + if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then + rm ${xui_folder}-linux-$(arch).tar.gz -f + echo -e "${red}Downloaded x-ui release archive is empty${plain}" + exit 1 + fi fi - curl -fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh + local xui_script_temp="/usr/bin/x-ui-temp.$$" + rm -f "${xui_script_temp}" + curl -fLRo "${xui_script_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh if [[ $? -ne 0 ]]; then + rm -f "${xui_script_temp}" echo -e "${red}Failed to download x-ui.sh${plain}" exit 1 fi + if [[ ! -s "${xui_script_temp}" ]]; then + rm -f "${xui_script_temp}" + echo -e "${red}Downloaded x-ui.sh is empty${plain}" + exit 1 + fi # Stop x-ui service and remove old resources if [[ -e ${xui_folder}/ ]]; then @@ -1391,9 +1443,20 @@ install_x-ui() { # Extract resources and set permissions tar zxvf x-ui-linux-$(arch).tar.gz + if [[ $? -ne 0 ]]; then + rm x-ui-linux-$(arch).tar.gz -f + rm -f "${xui_script_temp}" + echo -e "${red}Failed to extract the x-ui release archive -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}" + exit 1 + fi rm x-ui-linux-$(arch).tar.gz -f cd x-ui + if [[ $? -ne 0 || ! -s x-ui ]]; then + rm -f "${xui_script_temp}" + echo -e "${red}Extracted x-ui archive is missing the x-ui binary -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the installer again${plain}" + exit 1 + fi chmod +x x-ui chmod +x x-ui.sh @@ -1414,7 +1477,12 @@ install_x-ui() { fi # Update x-ui cli and se set permission - mv -f /usr/bin/x-ui-temp /usr/bin/x-ui + mv -f "${xui_script_temp}" /usr/bin/x-ui + if [[ $? -ne 0 ]]; then + rm -f "${xui_script_temp}" + echo -e "${red}Failed to install x-ui.sh${plain}" + exit 1 + fi chmod +x /usr/bin/x-ui mkdir -p /var/log/x-ui config_after_install @@ -1434,11 +1502,25 @@ install_x-ui() { fi if [[ $release == "alpine" ]]; then - curl -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc + xui_rc_temp="/etc/init.d/x-ui.tmp.$$" + rm -f "${xui_rc_temp}" + curl -fLRo "${xui_rc_temp}" https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc if [[ $? -ne 0 ]]; then + rm -f "${xui_rc_temp}" echo -e "${red}Failed to download x-ui.rc${plain}" exit 1 fi + if [[ ! -s "${xui_rc_temp}" ]]; then + rm -f "${xui_rc_temp}" + echo -e "${red}Downloaded x-ui.rc is empty${plain}" + exit 1 + fi + mv -f "${xui_rc_temp}" /etc/init.d/x-ui + if [[ $? -ne 0 ]]; then + rm -f "${xui_rc_temp}" + echo -e "${red}Failed to install x-ui.rc${plain}" + exit 1 + fi chmod +x /etc/init.d/x-ui rc-update add x-ui rc-service x-ui start @@ -1448,8 +1530,7 @@ install_x-ui() { if [ -f "x-ui.service" ]; then echo -e "${green}Found x-ui.service in extracted files, installing...${plain}" - cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then + if _install_xui_service_unit "x-ui.service" "false"; then service_installed=true fi fi @@ -1459,8 +1540,7 @@ install_x-ui() { ubuntu | debian | armbian) if [ -f "x-ui.service.debian" ]; then echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}" - cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then + if _install_xui_service_unit "x-ui.service.debian" "false"; then service_installed=true fi fi @@ -1468,8 +1548,7 @@ install_x-ui() { arch | manjaro | parch) if [ -f "x-ui.service.arch" ]; then echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}" - cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then + if _install_xui_service_unit "x-ui.service.arch" "false"; then service_installed=true fi fi @@ -1477,8 +1556,7 @@ install_x-ui() { *) if [ -f "x-ui.service.rhel" ]; then echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}" - cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then + if _install_xui_service_unit "x-ui.service.rhel" "false"; then service_installed=true fi fi @@ -1491,17 +1569,17 @@ install_x-ui() { echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}" case "${release}" in ubuntu | debian | armbian) - curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1 + service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian" ;; arch | manjaro | parch) - curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1 + service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch" ;; *) - curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1 + service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel" ;; esac - if [[ $? -ne 0 ]]; then + if ! _install_xui_service_unit "$service_unit_url" "true"; then echo -e "${red}Failed to install x-ui.service from GitHub${plain}" exit 1 fi diff --git a/internal/config/config.go b/internal/config/config.go index 080fc4b4f..0f6817b23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -169,6 +169,14 @@ func GetDBPath() string { return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName()) } +// GetUpdateStatusFilePath returns the path to the panel self-update status +// file update.sh writes on completion. It lives beside the database, outside +// XUI_MAIN_FOLDER, so it survives an update regardless of what happens to +// that folder. +func GetUpdateStatusFilePath() string { + return filepath.Join(GetDBFolderPath(), "update-status.json") +} + // GetDBKind returns the configured database backend: "sqlite" (default) or "postgres". func GetDBKind() string { v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE"))) diff --git a/internal/web/controller/panel_update_test.go b/internal/web/controller/panel_update_test.go new file mode 100644 index 000000000..4531467d4 --- /dev/null +++ b/internal/web/controller/panel_update_test.go @@ -0,0 +1,152 @@ +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "runtime" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + + "github.com/gin-gonic/gin" +) + +// newPanelUpdateTestEngine registers only updatePanel/getUpdateStatus directly +// on the controller's zero value, bypassing NewServerController's cron/metrics +// setup (unrelated to these two handlers, and unnecessary weight for a unit +// test). Callers must set up a DB first (newHostTestDB(t)) since StartUpdate +// reads the dev-channel setting before doing anything else. +func newPanelUpdateTestEngine() *gin.Engine { + a := &ServerController{} + engine := gin.New() + engine.GET("/panel/api/server/getUpdateStatus", a.getUpdateStatus) + engine.POST("/panel/api/server/updatePanel", a.updatePanel) + return engine +} + +func doPanelUpdateReq(t *testing.T, engine *gin.Engine, method, path string) hostEnvelope { + t.Helper() + req := httptest.NewRequest(method, path, nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String()) + } + var env hostEnvelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String()) + } + return env +} + +// TestGetUpdateStatus_NoStatusFileYet exercises the read-only status endpoint +// with no prior update having run: it must report "pending" (not an error), +// since a missing status file is an expected, ordinary state, not a failure. +func TestGetUpdateStatus_NoStatusFileYet(t *testing.T) { + newHostTestDB(t) + engine := newPanelUpdateTestEngine() + + env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus") + if !env.Success { + t.Fatalf("getUpdateStatus should always report success=true (it's a best-effort read): msg=%s", env.Msg) + } + var status struct { + RunID string `json:"runId"` + State string `json:"state"` + } + if err := json.Unmarshal(env.Obj, &status); err != nil { + t.Fatalf("decode status: %v", err) + } + if status.State != "pending" { + t.Fatalf("State = %q, want %q", status.State, "pending") + } +} + +// TestGetUpdateStatus_RunIdIsAlwaysAString is the regression test for the +// precision bug found in review: RunID is a 19-digit UnixNano timestamp, so +// it must round-trip over the wire as a JSON string, never a bare number -- a +// bare number would silently lose precision in JavaScript past +// Number.MAX_SAFE_INTEGER, breaking every future runId comparison on the +// frontend. Decoding into a Go string field below only succeeds if the wire +// value is actually a JSON string; a bare number there would fail to decode, +// so this test doubles as the wire-format check. +func TestGetUpdateStatus_RunIdIsAlwaysAString(t *testing.T) { + newHostTestDB(t) + engine := newPanelUpdateTestEngine() + + statusPath := config.GetUpdateStatusFilePath() + body := `{"runId":"1735689600123456789","state":"success","exitCode":0,"finishedAt":1735689612}` + if err := os.WriteFile(statusPath, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + env := doPanelUpdateReq(t, engine, http.MethodGet, "/panel/api/server/getUpdateStatus") + var status struct { + RunID string `json:"runId"` + State string `json:"state"` + } + if err := json.Unmarshal(env.Obj, &status); err != nil { + t.Fatalf("decode status (would fail here if runId were a bare JSON number instead of a string): %v, body=%s", err, env.Obj) + } + if status.RunID != "1735689600123456789" { + t.Fatalf("RunID = %q, want %q", status.RunID, "1735689600123456789") + } + if status.State != "success" { + t.Fatalf("State = %q, want %q", status.State, "success") + } +} + +// TestUpdatePanel_UnsupportedPlatformReturnsNoRunId covers the one path of +// updatePanel that's safe to exercise in an automated test on any OS/CI +// runner: the runtime.GOOS != "linux" guard. Actually invoking StartUpdate's +// launch logic on Linux would make a real network call and could launch a +// real update.sh process, so that path is deliberately not covered here -- +// see the PR description for why. +func TestUpdatePanel_UnsupportedPlatformReturnsNoRunId(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("this test only exercises the non-Linux guard path; on Linux, updatePanel would attempt a real download/exec") + } + newHostTestDB(t) + engine := newPanelUpdateTestEngine() + + env := doPanelUpdateReq(t, engine, http.MethodPost, "/panel/api/server/updatePanel") + if env.Success { + t.Fatal("updatePanel on an unsupported platform: success = true, want false") + } + if len(env.Obj) != 0 && string(env.Obj) != "null" { + t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj) + } +} + +// TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch covers the one branch of +// updatePanel that's both untested and safe to exercise on any OS/CI runner: +// an unparseable "dev" form value is rejected by strconv.ParseBool before +// StartUpdateChannel (and therefore any real exec/network call) is ever +// reached, on Linux or otherwise. +func TestUpdatePanel_InvalidDevValueRejectedBeforeLaunch(t *testing.T) { + newHostTestDB(t) + engine := newPanelUpdateTestEngine() + + form := url.Values{"dev": {"notabool"}} + req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d, body=%s", w.Code, w.Body.String()) + } + var env hostEnvelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v body=%s", err, w.Body.String()) + } + if env.Success { + t.Fatal("updatePanel with dev=notabool: success = true, want false") + } + if len(env.Obj) != 0 && string(env.Obj) != "null" { + t.Fatalf("updatePanel error response must not carry an obj/runId: got %s", env.Obj) + } +} diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index c57c95c9e..11c512a1b 100644 --- a/internal/web/controller/server.go +++ b/internal/web/controller/server.go @@ -51,6 +51,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket) g.GET("/getXrayVersion", a.getXrayVersion) g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) + g.GET("/getUpdateStatus", a.getUpdateStatus) g.GET("/getConfigJson", a.getConfigJson) g.GET("/getDb", a.getDb) g.GET("/getMigration", a.getMigration) @@ -209,21 +210,34 @@ func (a *ServerController) installXray(c *gin.Context) { // updatePanel starts a panel self-update. With no "dev" form value it follows // this panel's own channel setting; an explicit "dev" (sent by the master node -// updater) overrides it for this run. +// updater) overrides it for this run. The response's runId identifies this +// update for a later getUpdateStatus poll. func (a *ServerController) updatePanel(c *gin.Context) { devParam := c.PostForm("dev") + var runID int64 var err error if devParam == "" { - err = a.panelService.StartUpdate() + runID, err = a.panelService.StartUpdate() } else { dev, perr := strconv.ParseBool(devParam) if perr != nil { jsonMsg(c, "invalid data", perr) return } - err = a.panelService.StartUpdateChannel(dev) + runID, err = a.panelService.StartUpdateChannel(dev) } - jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err) + var obj any + if err == nil { + obj = gin.H{"runId": strconv.FormatInt(runID, 10)} + } + jsonMsgObj(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), obj, err) +} + +// getUpdateStatus reports the outcome of the most recently launched panel +// self-update (see updatePanel). Compare the returned runId against the one +// updatePanel returned to tell this run's result apart from a stale one. +func (a *ServerController) getUpdateStatus(c *gin.Context) { + jsonObj(c, a.panelService.GetUpdateStatus(), nil) } // setUpdateChannel toggles whether self-update tracks the rolling dev release. diff --git a/internal/web/service/panel/panel.go b/internal/web/service/panel/panel.go index e53d5adc1..f9ca5d9c3 100644 --- a/internal/web/service/panel/panel.go +++ b/internal/web/service/panel/panel.go @@ -13,6 +13,7 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" @@ -44,10 +45,64 @@ const ( // devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the // newest main commit; the dev update channel installs from it. devReleaseTag = "dev-latest" + + updateStatePending = "pending" + updateStateSuccess = "success" + updateStateFailed = "failed" ) +// PanelUpdateStatus reports the outcome of the most recently launched panel +// self-update. RunID lets the caller confirm this status belongs to the +// update it started rather than a stale result left over from an earlier +// run; State is one of "pending", "success", or "failed". RunID is a decimal +// string, not a JSON number: it's a formatted UnixNano timestamp, and +// JavaScript's number type can't represent that precisely (it exceeds +// Number.MAX_SAFE_INTEGER), which would let two different runs round to the +// same value on the wire and defeat the whole point of this field. +type PanelUpdateStatus struct { + RunID string `json:"runId" example:"1735689600123456789"` + State string `json:"state" example:"success"` + ExitCode int `json:"exitCode" example:"0"` + FinishedAt int64 `json:"finishedAt" example:"1735689612"` +} + var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`) +// updateMu guards updateRunning/updateStarted/updateRunID/updatePID, which +// stop a second self-update from launching while one is still in flight (two +// concurrent update.sh runs would race each other extracting the release +// tarball and swapping the service unit). A slot is released as soon as the +// in-flight run's own status file reports success or failure -- checked +// against updateRunID so a stale file from an even earlier run can't be +// mistaken for this one finishing -- so a fast failure doesn't lock out a +// retry. +// +// For a run that never reaches a terminal state at all, staleness is judged +// primarily by whether the process we actually launched is still alive +// (updatePID, via processAlive), not by wall-clock time alone: update.sh +// runs install_base() (a package-manager update+install) before anything +// else, plus several downloads, which can legitimately run past a short +// fixed timeout on a slow or throttled host without anything being wrong. +// updateStaleAfter/updatePID together are only a fallback for the systemd-run +// launch path, where the process we can observe (systemd-run itself) has +// already exited by the time startUpdate returns and the actual update.sh +// unit's PID is never recorded -- for that path this is still a pure +// wall-clock heuristic. updateHardCeiling is an absolute backstop so a +// genuinely wedged run (alive but hung forever) can never lock out retries +// permanently, even on the PID-tracked path. +var ( + updateMu sync.Mutex + updateRunning bool + updateStarted time.Time + updateRunID int64 + updatePID int +) + +const ( + updateStaleAfter = 20 * time.Minute + updateHardCeiling = 2 * time.Hour +) + func (s *PanelService) RestartPanel(delay time.Duration) error { go func() { time.Sleep(delay) @@ -122,39 +177,77 @@ func getDevUpdateInfo() (*PanelUpdateInfo, error) { }, nil } -// StartUpdate starts the official updater using this panel's own channel setting. -func (s *PanelService) StartUpdate() error { +// StartUpdate starts the official updater using this panel's own channel +// setting. Returns the run ID to pass to GetUpdateStatus so the caller can +// tell this run's result apart from a stale one. +func (s *PanelService) StartUpdate() (int64, error) { return s.startUpdate(devChannelActive()) } // StartUpdateChannel runs the updater against an explicitly chosen channel, // overriding the local dev-channel setting. Used by the master node updater so // a node can be moved to the dev channel from the central panel. -func (s *PanelService) StartUpdateChannel(dev bool) error { +func (s *PanelService) StartUpdateChannel(dev bool) (int64, error) { return s.startUpdate(dev) } -func (s *PanelService) startUpdate(useDev bool) error { +// GetUpdateStatus reports the outcome of the most recently launched panel +// self-update, as recorded by update.sh's EXIT trap (see the script for why +// that covers every exit path, not just the happy one). This is a best-effort +// side channel: a missing or unreadable status file reads as "pending" +// rather than an error, since the update itself is what matters, not this +// status file. +func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus { + data, err := os.ReadFile(config.GetUpdateStatusFilePath()) + if err != nil { + return &PanelUpdateStatus{State: updateStatePending} + } + var status PanelUpdateStatus + if err := json.Unmarshal(data, &status); err != nil { + return &PanelUpdateStatus{State: updateStatePending} + } + if status.State != updateStateSuccess && status.State != updateStateFailed { + status.State = updateStatePending + } + return &status +} + +func (s *PanelService) startUpdate(useDev bool) (int64, error) { + runID := time.Now().UnixNano() + if !acquireUpdateSlot(runID) { + return 0, fmt.Errorf("a panel update is already in progress") + } + launched := false + defer func() { + if !launched { + releaseUpdateSlot() + } + }() + if runtime.GOOS != "linux" { - return fmt.Errorf("panel web update is supported only on Linux installations") + return 0, fmt.Errorf("panel web update is supported only on Linux installations") } bash, err := exec.LookPath("bash") if err != nil { - return fmt.Errorf("bash is required to run the panel updater: %w", err) + return 0, fmt.Errorf("bash is required to run the panel updater: %w", err) } scriptPath, err := downloadPanelUpdater() if err != nil { - return err + return 0, err } + statusFile := config.GetUpdateStatusFilePath() + mainFolder, serviceFolder := resolveUpdateFolders() updateTag := "" if useDev { updateTag = devReleaseTag } 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 if systemdRun, err := exec.LookPath("systemd-run"); err == nil { unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix()) @@ -163,6 +256,8 @@ func (s *PanelService) startUpdate(useDev bool) error { "--setenv", "XUI_MAIN_FOLDER="+mainFolder, "--setenv", "XUI_SERVICE="+serviceFolder, "--setenv", "XUI_UPDATE_TAG="+updateTag, + "--setenv", runIDEnv, + "--setenv", statusFileEnv, bash, "-lc", updateScript, ) out, err := cmd.CombinedOutput() @@ -171,12 +266,13 @@ func (s *PanelService) startUpdate(useDev bool) error { if !strings.Contains(output, "System has not been booted with systemd") && !strings.Contains(output, "Failed to connect to bus") { _ = os.Remove(scriptPath) - return fmt.Errorf("failed to start panel update job: %w: %s", err, output) + return 0, fmt.Errorf("failed to start panel update job: %w: %s", err, output) } logger.Warning("systemd-run is unavailable, falling back to detached update process:", output) } else { logger.Infof("started panel update job via systemd-run unit %s", unitName) - return nil + launched = true + return runID, nil } } @@ -185,17 +281,77 @@ func (s *PanelService) startUpdate(useDev bool) error { "XUI_MAIN_FOLDER="+mainFolder, "XUI_SERVICE="+serviceFolder, "XUI_UPDATE_TAG="+updateTag, + runIDEnv, + statusFileEnv, ) setDetachedProcess(cmd) if err := cmd.Start(); err != nil { _ = os.Remove(scriptPath) - return fmt.Errorf("failed to start panel update job: %w", err) + return 0, fmt.Errorf("failed to start panel update job: %w", err) } if err := cmd.Process.Release(); err != nil { logger.Warning("failed to release panel update process:", err) } logger.Infof("started panel update job with pid %d", cmd.Process.Pid) - return nil + recordUpdatePID(cmd.Process.Pid) + launched = true + return runID, nil +} + +// acquireUpdateSlot claims the single in-flight-update slot for runID. It +// refuses while another run is genuinely still in flight, but grants the +// slot immediately once that run's own status file reports a terminal +// result (success or failure) -- a fast failure shouldn't force the next +// attempt to wait out updateStaleAfter for no reason. Past updateStaleAfter +// with no terminal status yet, it grants the slot anyway UNLESS the process +// we recorded (updatePID) is confirmed still alive, so a merely-slow run +// isn't mistaken for a crashed one; past updateHardCeiling it grants the +// slot unconditionally regardless of liveness, so a truly wedged run can +// never lock out retries forever. +func acquireUpdateSlot(runID int64) bool { + updateMu.Lock() + defer updateMu.Unlock() + if updateRunning && !previousRunIsTerminal() { + elapsed := time.Since(updateStarted) + if elapsed < updateHardCeiling { + stale := elapsed >= updateStaleAfter + alive := updatePID > 0 && processAlive(updatePID) + if !stale || alive { + return false + } + } + } + updateRunning = true + updateStarted = time.Now() + updateRunID = runID + updatePID = 0 + return true +} + +// recordUpdatePID notes the PID of the detached update.sh process the +// current slot is tracking, so a later acquireUpdateSlot call can check +// whether it is actually still running instead of only how long ago it +// started. Only reachable for the detached-fallback launch path -- the +// systemd-run path never learns update.sh's own PID, since the process it +// directly observes (systemd-run) has already exited by the time it returns. +func recordUpdatePID(pid int) { + updateMu.Lock() + updatePID = pid + updateMu.Unlock() +} + +// previousRunIsTerminal reports whether the run currently recorded in +// updateRunID has reached success or failure per its status file. Must be +// called with updateMu held. +func previousRunIsTerminal() bool { + status := (&PanelService{}).GetUpdateStatus() + return status.RunID == strconv.FormatInt(updateRunID, 10) && status.State != updateStatePending +} + +func releaseUpdateSlot() { + updateMu.Lock() + updateRunning = false + updateMu.Unlock() } func downloadPanelUpdater() (string, error) { @@ -230,6 +386,9 @@ func downloadPanelUpdater() (string, error) { if err != nil { return "", fmt.Errorf("write panel updater: %w", err) } + if n == 0 { + return "", fmt.Errorf("panel updater download is empty") + } if n > maxPanelUpdaterBytes { return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes) } diff --git a/internal/web/service/panel/panel_other.go b/internal/web/service/panel/panel_other.go index ef28394cd..a768f5225 100644 --- a/internal/web/service/panel/panel_other.go +++ b/internal/web/service/panel/panel_other.go @@ -5,3 +5,10 @@ package panel import "os/exec" func setDetachedProcess(cmd *exec.Cmd) {} + +// processAlive is never meaningfully consulted outside Linux: startUpdate +// itself is gated to runtime.GOOS == "linux" before any process is ever +// launched, so no real PID is ever recorded on this platform. +func processAlive(pid int) bool { + return false +} diff --git a/internal/web/service/panel/panel_test.go b/internal/web/service/panel/panel_test.go index 94dc8d122..cd5e642bf 100644 --- a/internal/web/service/panel/panel_test.go +++ b/internal/web/service/panel/panel_test.go @@ -1,8 +1,15 @@ package panel import ( + "fmt" + "os" + "runtime" + "sync" + "sync/atomic" "testing" + "time" + "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) @@ -107,3 +114,207 @@ func TestShortCommit(t *testing.T) { t.Fatalf("shortCommit short input = %q, want %q", got, "abc") } } + +func resetUpdateSlot(t *testing.T) { + t.Helper() + t.Cleanup(func() { + updateMu.Lock() + updateRunning = false + updateRunID = 0 + updatePID = 0 + updateMu.Unlock() + }) +} + +// writeStatusFile hand-writes the status file in the exact wire format +// update.sh itself produces (a bare printf, not Go's json.Marshal), since +// that's the real cross-language contract this package reads in production. +func writeStatusFile(t *testing.T, path string, runID int64, state string) { + t.Helper() + body := fmt.Sprintf(`{"runId":"%d","state":"%s","exitCode":0,"finishedAt":%d}`, runID, state, time.Now().Unix()) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestAcquireUpdateSlot(t *testing.T) { + resetUpdateSlot(t) + + if !acquireUpdateSlot(1) { + t.Fatal("first acquire: got false, want true") + } + if acquireUpdateSlot(2) { + t.Fatal("second acquire while first is held: got true, want false") + } + releaseUpdateSlot() + if !acquireUpdateSlot(3) { + t.Fatal("acquire after release: got false, want true") + } + releaseUpdateSlot() +} + +func TestAcquireUpdateSlotExpiresAfterStaleWindow(t *testing.T) { + resetUpdateSlot(t) + + if !acquireUpdateSlot(1) { + t.Fatal("first acquire: got false, want true") + } + updateMu.Lock() + updateStarted = time.Now().Add(-(updateStaleAfter + time.Second)) + updateMu.Unlock() + + if !acquireUpdateSlot(2) { + t.Fatal("acquire after stale window elapsed: got false, want true") + } + releaseUpdateSlot() +} + +// TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow is the regression +// test for the concurrency bug an upstream review found: past +// updateStaleAfter, the old logic freed the slot purely on elapsed time, even +// if the process it launched was still genuinely running (not crashed) -- +// update.sh's own package-manager step plus several downloads can plausibly +// run long on a slow host with nothing actually wrong. Now a confirmed-alive +// PID keeps the slot held past the stale window. +func TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux") + } + resetUpdateSlot(t) + + if !acquireUpdateSlot(1) { + t.Fatal("first acquire: got false, want true") + } + recordUpdatePID(os.Getpid()) // the test process itself: guaranteed alive + updateMu.Lock() + updateStarted = time.Now().Add(-(updateStaleAfter + time.Second)) + updateMu.Unlock() + + if acquireUpdateSlot(2) { + t.Fatal("acquire past the stale window while the recorded PID is still alive: got true, want false") + } + releaseUpdateSlot() +} + +// TestAcquireUpdateSlotHardCeilingOverridesLiveness confirms the absolute +// backstop: even a confirmed-alive process can't hold the slot forever, so a +// genuinely wedged run can't lock out retries permanently. +func TestAcquireUpdateSlotHardCeilingOverridesLiveness(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux") + } + resetUpdateSlot(t) + + if !acquireUpdateSlot(1) { + t.Fatal("first acquire: got false, want true") + } + recordUpdatePID(os.Getpid()) + updateMu.Lock() + updateStarted = time.Now().Add(-(updateHardCeiling + time.Second)) + updateMu.Unlock() + + if !acquireUpdateSlot(2) { + t.Fatal("acquire past the hard ceiling despite a live PID: got false, want true") + } + releaseUpdateSlot() +} + +// TestAcquireUpdateSlotReleasesOnTerminalStatus is the regression test for the +// bug adversarial review found: a fast failure used to still lock out retries +// for the full updateStaleAfter window, because acquireUpdateSlot only looked +// at the in-memory started-at timestamp, never at the status file's own +// terminal state. +func TestAcquireUpdateSlotReleasesOnTerminalStatus(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + resetUpdateSlot(t) + path := config.GetUpdateStatusFilePath() + + if !acquireUpdateSlot(111) { + t.Fatal("first acquire: got false, want true") + } + writeStatusFile(t, path, 111, updateStateFailed) + + if !acquireUpdateSlot(222) { + t.Fatal("acquire after the in-flight run reported failed: got false, want true (should not wait out updateStaleAfter)") + } + releaseUpdateSlot() +} + +// TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus confirms the terminal-state +// check is scoped to the run it actually launched: a status file left behind +// by some earlier, unrelated run (different runID) must not be mistaken for +// this run finishing. +func TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + resetUpdateSlot(t) + path := config.GetUpdateStatusFilePath() + + writeStatusFile(t, path, 999, updateStateSuccess) + if !acquireUpdateSlot(111) { + t.Fatal("first acquire: got false, want true") + } + + if acquireUpdateSlot(222) { + t.Fatal("acquire while status file only reflects an unrelated older runID: got true, want false") + } + releaseUpdateSlot() +} + +// TestAcquireUpdateSlotConcurrency proves the check-then-set is actually +// atomic under real concurrent access, not just correct when called +// sequentially. A prior version of this test suite only ever called +// acquireUpdateSlot from a single goroutine, so it gave no signal if the +// mutex's core promise (only one concurrent launch wins) were broken. +func TestAcquireUpdateSlotConcurrency(t *testing.T) { + resetUpdateSlot(t) + + const attempts = 200 + var wins atomic.Int32 + var wg sync.WaitGroup + wg.Add(attempts) + for i := range attempts { + go func(runID int64) { + defer wg.Done() + if acquireUpdateSlot(runID) { + wins.Add(1) + } + }(int64(i)) + } + wg.Wait() + + if got := wins.Load(); got != 1 { + t.Fatalf("concurrent acquireUpdateSlot: %d of %d attempts won, want exactly 1", got, attempts) + } + releaseUpdateSlot() +} + +func TestGetUpdateStatus(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + path := config.GetUpdateStatusFilePath() + svc := &PanelService{} + + if got := svc.GetUpdateStatus(); got.State != updateStatePending { + t.Fatalf("missing status file: State = %q, want %q", got.State, updateStatePending) + } + + writeStatusFile(t, path, 1735689600123456789, updateStateSuccess) + got := svc.GetUpdateStatus() + if got.RunID != "1735689600123456789" { + t.Fatalf("RunID = %q, want %q (must round-trip as a decimal string, not a JSON number, or it loses precision past 2^53 in JS)", got.RunID, "1735689600123456789") + } + if got.State != updateStateSuccess { + t.Fatalf("State = %q, want %q", got.State, updateStateSuccess) + } + + if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + if got := svc.GetUpdateStatus(); got.State != updateStatePending { + t.Fatalf("corrupt status file: State = %q, want %q", got.State, updateStatePending) + } + + writeStatusFile(t, path, 1, "some-unrecognized-state") + if got := svc.GetUpdateStatus(); got.State != updateStatePending { + t.Fatalf("unrecognized state normalizes to pending: State = %q, want %q", got.State, updateStatePending) + } +} diff --git a/internal/web/service/panel/panel_unix.go b/internal/web/service/panel/panel_unix.go index 3ca983b09..980167ba7 100644 --- a/internal/web/service/panel/panel_unix.go +++ b/internal/web/service/panel/panel_unix.go @@ -10,3 +10,17 @@ import ( func setDetachedProcess(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} } + +// processAlive reports whether pid is still a live process, via the standard +// POSIX kill(pid, 0) liveness check: it sends no actual signal, only checking +// whether the target exists and is signalable. ESRCH means the process is +// gone; any other result (including a permission error, which can only mean +// the PID exists and belongs to someone) is treated as alive, since this is +// used to decide whether it is safe to let a second update start. +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 8fb0e6e56..949737a95 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "ده هيحدث 3X-UI للإصدار #version# وهيعيد تشغيل البانل.", "panelUpdateCheckPopover": "فشل التحقق من تحديث البانل", "panelUpdateStartedPopover": "بدأ تحديث البانل", + "panelUpdateFailedTitle": "فشل تحديث البانل", + "panelUpdateFailedDesc": "لم يكتمل التحديث بنجاح. تحقق من سجلات الخادم، أو نفّذ الأمر «x-ui update» من سطر الأوامر.", + "panelUpdateUnknownTitle": "تعذّر التأكد من اكتمال التحديث", + "panelUpdateUnknownDesc": "لم تُبلغ اللوحة بنتيجة في الوقت المحدد. أعد تحميل الصفحة للتحقق من الإصدار الحالي، أو تحقق من سجلات الخادم.", "geofileUpdateDialog": "هل تريد حقًا تحديث ملف الجغرافيا؟", "geofileUpdateDialogDesc": "سيؤدي هذا إلى تحديث ملف #filename#.", "geofilesUpdateDialogDesc": "سيؤدي هذا إلى تحديث كافة الملفات.", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index e6008918b..4055939be 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "This will update 3X-UI to #version# and restart the panel service.", "panelUpdateCheckPopover": "Panel update check failed", "panelUpdateStartedPopover": "Panel update started", + "panelUpdateFailedTitle": "Panel update failed", + "panelUpdateFailedDesc": "The update did not finish successfully. Check the server logs, or run 'x-ui update' from the command line.", + "panelUpdateUnknownTitle": "Couldn't confirm the update finished", + "panelUpdateUnknownDesc": "The panel didn't report a result in time. Reload to check the current version, or check the server logs.", "geofileUpdateDialog": "Do you really want to update the geofile?", "geofileUpdateDialogDesc": "This will update the #filename# file.", "geofilesUpdateDialogDesc": "This will update all geofiles.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index e5b673be4..30a8f105b 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "Esto actualizará 3X-UI a la versión #version# y reiniciará el servicio del panel.", "panelUpdateCheckPopover": "Fallo al comprobar actualización del panel", "panelUpdateStartedPopover": "Actualización del panel iniciada", + "panelUpdateFailedTitle": "Error al actualizar el panel", + "panelUpdateFailedDesc": "La actualización no se completó correctamente. Revisa los registros del servidor o ejecuta 'x-ui update' desde la línea de comandos.", + "panelUpdateUnknownTitle": "No se pudo confirmar si la actualización terminó", + "panelUpdateUnknownDesc": "El panel no informó un resultado a tiempo. Recarga la página para comprobar la versión actual, o revisa los registros del servidor.", "geofileUpdateDialog": "¿Realmente deseas actualizar el geofichero?", "geofileUpdateDialogDesc": "Esto actualizará el archivo #filename#.", "geofilesUpdateDialogDesc": "Esto actualizará todos los archivos.", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 231c6b320..5292fae54 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "این 3X-UI را به نسخه #version# به‌روزرسانی کرده و سرویس پنل را مجدداً راه‌اندازی می‌کند.", "panelUpdateCheckPopover": "خطا در بررسی به‌روزرسانی پنل", "panelUpdateStartedPopover": "به‌روزرسانی پنل آغاز شد", + "panelUpdateFailedTitle": "به‌روزرسانی پنل ناموفق بود", + "panelUpdateFailedDesc": "به‌روزرسانی با موفقیت به پایان نرسید. گزارش‌های سرور را بررسی کنید یا دستور «x-ui update» را از خط فرمان اجرا کنید.", + "panelUpdateUnknownTitle": "تأیید نشد که به‌روزرسانی به پایان رسیده باشد", + "panelUpdateUnknownDesc": "پنل به‌موقع نتیجه‌ای گزارش نکرد. صفحه را بارگذاری مجدد کنید تا نسخه فعلی را بررسی کنید یا گزارش‌های سرور را بررسی کنید.", "geofileUpdateDialog": "آیا واقعاً می‌خواهید فایل جغرافیایی را به‌روز کنید؟", "geofileUpdateDialogDesc": "این عمل فایل #filename# را به‌روز می‌کند.", "geofilesUpdateDialogDesc": "با این کار همه فایل‌ها به‌روزرسانی می‌شوند.", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 1f00bae02..5951489e9 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "Ini akan memperbarui 3X-UI ke #version# dan me-restart layanan panel.", "panelUpdateCheckPopover": "Pemeriksaan pembaruan panel gagal", "panelUpdateStartedPopover": "Pembaruan panel dimulai", + "panelUpdateFailedTitle": "Pembaruan panel gagal", + "panelUpdateFailedDesc": "Pembaruan tidak selesai dengan sukses. Periksa log server, atau jalankan 'x-ui update' dari baris perintah.", + "panelUpdateUnknownTitle": "Tidak dapat memastikan pembaruan selesai", + "panelUpdateUnknownDesc": "Panel tidak melaporkan hasil tepat waktu. Muat ulang untuk memeriksa versi saat ini, atau periksa log server.", "geofileUpdateDialog": "Apakah Anda yakin ingin memperbarui geofile?", "geofileUpdateDialogDesc": "Ini akan memperbarui file #filename#.", "geofilesUpdateDialogDesc": "Ini akan memperbarui semua berkas.", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 64ef1d49a..13c00d6bd 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "これにより3X-UIが#version#に更新され、パネルサービスが再起動されます。", "panelUpdateCheckPopover": "パネルの更新確認に失敗しました", "panelUpdateStartedPopover": "パネルの更新を開始しました", + "panelUpdateFailedTitle": "パネルの更新に失敗しました", + "panelUpdateFailedDesc": "更新が正常に完了しませんでした。サーバーのログを確認するか、コマンドラインで「x-ui update」を実行してください。", + "panelUpdateUnknownTitle": "更新が完了したか確認できませんでした", + "panelUpdateUnknownDesc": "パネルから時間内に結果が報告されませんでした。再読み込みして現在のバージョンを確認するか、サーバーのログを確認してください。", "geofileUpdateDialog": "ジオファイルを本当に更新しますか?", "geofileUpdateDialogDesc": "これにより#filename#ファイルが更新されます。", "geofilesUpdateDialogDesc": "これにより、すべてのファイルが更新されます。", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 8106c6dcd..8469292d1 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "Isso atualizará o 3X-UI para #version# e reiniciará o serviço do painel.", "panelUpdateCheckPopover": "Falha na verificação de atualização do painel", "panelUpdateStartedPopover": "Atualização do painel iniciada", + "panelUpdateFailedTitle": "Falha ao atualizar o painel", + "panelUpdateFailedDesc": "A atualização não foi concluída com sucesso. Verifique os logs do servidor ou execute 'x-ui update' na linha de comando.", + "panelUpdateUnknownTitle": "Não foi possível confirmar se a atualização terminou", + "panelUpdateUnknownDesc": "O painel não retornou um resultado a tempo. Recarregue a página para verificar a versão atual, ou verifique os logs do servidor.", "geofileUpdateDialog": "Você realmente deseja atualizar o geofile?", "geofileUpdateDialogDesc": "Isso atualizará o arquivo #filename#.", "geofilesUpdateDialogDesc": "Isso atualizará todos os arquivos.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 9909130d2..3664b317d 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "Это обновит 3X-UI до версии #version# и перезапустит сервис панели.", "panelUpdateCheckPopover": "Проверка обновления панели не удалась", "panelUpdateStartedPopover": "Обновление панели началось", + "panelUpdateFailedTitle": "Не удалось обновить панель", + "panelUpdateFailedDesc": "Обновление не завершилось успешно. Проверьте журналы сервера или выполните 'x-ui update' в командной строке.", + "panelUpdateUnknownTitle": "Не удалось подтвердить завершение обновления", + "panelUpdateUnknownDesc": "Панель не сообщила результат вовремя. Перезагрузите страницу, чтобы проверить текущую версию, или проверьте журналы сервера.", "geofileUpdateDialog": "Вы действительно хотите обновить геофайл?", "geofileUpdateDialogDesc": "Это обновит файл #filename#.", "geofilesUpdateDialogDesc": "Это обновит все геофайлы.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index c2b27a561..4d4637b02 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "Bu işlem 3X-UI'yi #version# sürümüne güncelleyecek ve panel servisini yeniden başlatacaktır.", "panelUpdateCheckPopover": "Panel güncelleme kontrolü başarısız oldu", "panelUpdateStartedPopover": "Panel güncellemesi başlatıldı", + "panelUpdateFailedTitle": "Panel güncellemesi başarısız oldu", + "panelUpdateFailedDesc": "Güncelleme başarıyla tamamlanamadı. Sunucu günlüklerini kontrol edin veya komut satırından 'x-ui update' çalıştırın.", + "panelUpdateUnknownTitle": "Güncellemenin tamamlandığı doğrulanamadı", + "panelUpdateUnknownDesc": "Panel zamanında bir sonuç bildirmedi. Geçerli sürümü kontrol etmek için sayfayı yenileyin veya sunucu günlüklerini kontrol edin.", "geofileUpdateDialog": "Geofile'ı gerçekten güncellemek istiyor musunuz?", "geofileUpdateDialogDesc": "Bu işlem #filename# dosyasını güncelleyecektir.", "geofilesUpdateDialogDesc": "Bu, tüm dosyaları güncelleyecektir.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 76559ad33..1db38a63f 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "Це оновить 3X-UI до #version# та перезапустить сервіс панелі.", "panelUpdateCheckPopover": "Перевірка оновлення панелі не вдалася", "panelUpdateStartedPopover": "Розпочато оновлення панелі", + "panelUpdateFailedTitle": "Не вдалося оновити панель", + "panelUpdateFailedDesc": "Оновлення не завершилося успішно. Перевірте журнали сервера або виконайте 'x-ui update' у командному рядку.", + "panelUpdateUnknownTitle": "Не вдалося підтвердити завершення оновлення", + "panelUpdateUnknownDesc": "Панель не повідомила результат вчасно. Перезавантажте сторінку, щоб перевірити поточну версію, або перевірте журнали сервера.", "geofileUpdateDialog": "Ви дійсно хочете оновити геофайл?", "geofileUpdateDialogDesc": "Це оновить файл #filename#.", "geofilesUpdateDialogDesc": "Це оновить усі геофайли.", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 622432c91..75b08fd27 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "Điều này sẽ cập nhật 3X-UI lên #version# và khởi động lại dịch vụ panel.", "panelUpdateCheckPopover": "Kiểm tra cập nhật panel thất bại", "panelUpdateStartedPopover": "Bắt đầu cập nhật panel", + "panelUpdateFailedTitle": "Cập nhật panel thất bại", + "panelUpdateFailedDesc": "Bản cập nhật không hoàn tất thành công. Hãy kiểm tra nhật ký máy chủ, hoặc chạy 'x-ui update' từ dòng lệnh.", + "panelUpdateUnknownTitle": "Không thể xác nhận việc cập nhật đã hoàn tất", + "panelUpdateUnknownDesc": "Panel không báo cáo kết quả kịp thời. Hãy tải lại trang để kiểm tra phiên bản hiện tại, hoặc kiểm tra nhật ký máy chủ.", "geofileUpdateDialog": "Bạn có chắc chắn muốn cập nhật geofile không?", "geofileUpdateDialogDesc": "Hành động này sẽ cập nhật tệp #filename#.", "geofilesUpdateDialogDesc": "Thao tác này sẽ cập nhật tất cả các tập tin.", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index e27612d28..411beae2a 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "这将把 3X-UI 更新到 #version# 并重启面板服务。", "panelUpdateCheckPopover": "面板更新检查失败", "panelUpdateStartedPopover": "已开始更新面板", + "panelUpdateFailedTitle": "面板更新失败", + "panelUpdateFailedDesc": "更新未成功完成。请检查服务器日志,或在命令行运行「x-ui update」。", + "panelUpdateUnknownTitle": "无法确认更新是否已完成", + "panelUpdateUnknownDesc": "面板未能及时返回结果。请刷新页面查看当前版本,或检查服务器日志。", "geofileUpdateDialog": "您确定要更新地理文件吗?", "geofileUpdateDialogDesc": "这将更新 #filename# 文件。", "geofilesUpdateDialogDesc": "这将更新所有文件。", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 5ab884dde..0aab8a1e9 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -231,6 +231,10 @@ "panelUpdateDialogDesc": "這將把 3X-UI 更新到 #version# 並重新啟動面板服務。", "panelUpdateCheckPopover": "面板更新檢查失敗", "panelUpdateStartedPopover": "面板更新已開始", + "panelUpdateFailedTitle": "面板更新失敗", + "panelUpdateFailedDesc": "更新未成功完成。請檢查伺服器日誌,或在命令列執行「x-ui update」。", + "panelUpdateUnknownTitle": "無法確認更新是否已完成", + "panelUpdateUnknownDesc": "面板未能及時回報結果。請重新整理以檢查目前版本,或檢查伺服器日誌。", "geofileUpdateDialog": "您確定要更新地理檔案嗎?", "geofileUpdateDialogDesc": "這將更新 #filename# 檔案。", "geofilesUpdateDialogDesc": "這將更新所有文件。", diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index 9d5b7ef1a..03de41669 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -83,7 +83,7 @@ func run(root, outDir string) error { }, { Path: resolveRel(root, "internal/web/service/panel"), - StructAllow: setOf("ApiTokenView"), + StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"), }, } diff --git a/update.sh b/update.sh index 0257bd929..002f72347 100755 --- a/update.sh +++ b/update.sh @@ -31,6 +31,40 @@ _fail() { exit 2 } +# Records this run's outcome for the panel's web updater to poll, since it +# launches this script detached and has no other way to learn whether it +# finished. Written to a fixed path outside XUI_MAIN_FOLDER so it survives +# the update regardless of what happens to that folder. The EXIT trap below +# covers every exit path in this file, including the bare `exit 1`/`exit 2` +# calls that don't go through _fail. +xui_update_run_id="${XUI_UPDATE_RUN_ID:-0}" +[[ "${xui_update_run_id}" =~ ^[0-9]+$ ]] || xui_update_run_id="0" +xui_update_status_file="${XUI_UPDATE_STATUS_FILE:-/etc/x-ui/update-status.json}" + +_write_update_status() { + local state="$1" + local exit_code="$2" + local status_dir + status_dir="$(dirname "${xui_update_status_file}")" + mkdir -p "${status_dir}" > /dev/null 2>&1 + local tmp_file="${xui_update_status_file}.tmp.$$" + printf '{"runId":"%s","state":"%s","exitCode":%s,"finishedAt":%s}\n' \ + "${xui_update_run_id}" "${state}" "${exit_code}" "$(date +%s)" > "${tmp_file}" 2> /dev/null + mv -f "${tmp_file}" "${xui_update_status_file}" > /dev/null 2>&1 +} + +_report_update_exit() { + local code=$? + if [[ "${code}" -eq 0 ]]; then + _write_update_status "success" "0" + else + _write_update_status "failed" "${code}" + fi +} +trap _report_update_exit EXIT +trap 'exit 143' TERM +trap 'exit 130' INT + # check root [[ $EUID -ne 0 ]] && _fail "FATAL ERROR: Please run this script with root privilege." @@ -881,6 +915,40 @@ setup_fail2ban() { return 0 } +# Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file + +# atomic mv, so a failed cp/curl or an interrupted mv never leaves a +# truncated unit file at the live path -- systemd would then fail to parse +# it on the next daemon-reload/start. Same pattern already used for +# /usr/bin/x-ui elsewhere in this script. source_is_url picks cp (from a +# file already extracted from the release tarball) vs curl (GitHub fallback). +_install_xui_service_unit() { + local source="$1" + local source_is_url="$2" + local dest="${xui_service}/x-ui.service" + local temp_file="${dest}.tmp.$$" + + rm -f "$temp_file" + if [[ "$source_is_url" == "true" ]]; then + ${curl_bin} -fLRo "$temp_file" "$source" > /dev/null 2>&1 + else + cp -f "$source" "$temp_file" > /dev/null 2>&1 + fi + if [[ $? -ne 0 ]]; then + rm -f "$temp_file" + return 1 + fi + if [[ ! -s "$temp_file" ]]; then + rm -f "$temp_file" + return 1 + fi + mv -f "$temp_file" "$dest" + if [[ $? -ne 0 ]]; then + rm -f "$temp_file" + return 1 + fi + return 0 +} + update_x-ui() { cd ${xui_folder%/x-ui}/ @@ -911,6 +979,10 @@ update_x-ui() { if [[ $? -ne 0 ]]; then _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub" fi + if [[ ! -s ${xui_folder}-linux-$(arch).tar.gz ]]; then + rm ${xui_folder}-linux-$(arch).tar.gz -f > /dev/null 2>&1 + _fail "ERROR: Downloaded x-ui release archive is empty, please be sure that your server can access GitHub" + fi if [[ -e ${xui_folder}/ ]]; then echo -e "${green}Stopping x-ui...${plain}" @@ -961,8 +1033,15 @@ update_x-ui() { echo -e "${green}Installing new x-ui version...${plain}" tar zxvf x-ui-linux-$(arch).tar.gz > /dev/null 2>&1 + if [[ $? -ne 0 ]]; then + rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1 + _fail "ERROR: Failed to extract the x-ui release archive -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the update again" + fi rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1 cd x-ui > /dev/null 2>&1 + if [[ $? -ne 0 || ! -s x-ui ]]; then + _fail "ERROR: Extracted x-ui archive is missing the x-ui binary -- the previous installation has already been removed, so the panel will not start until this is fixed; try running the update again" + fi chmod +x x-ui > /dev/null 2>&1 # Check the system's architecture and rename the file accordingly @@ -974,10 +1053,22 @@ update_x-ui() { chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1 echo -e "${green}Downloading and installing x-ui.sh script...${plain}" - ${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1 + 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/main/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 + rm -f "${xui_script_temp}" + _fail "ERROR: Failed to install x-ui.sh script" + fi chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1 chmod +x /usr/bin/x-ui > /dev/null 2>&1 @@ -993,10 +1084,22 @@ update_x-ui() { if [[ $release == "alpine" ]]; then echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}" - ${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1 + 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/main/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 + mv -f "${xui_rc_temp}" /etc/init.d/x-ui + if [[ $? -ne 0 ]]; then + rm -f "${xui_rc_temp}" + _fail "ERROR: Failed to install startup unit x-ui.rc" + fi chmod +x /etc/init.d/x-ui > /dev/null 2>&1 chown root:root /etc/init.d/x-ui > /dev/null 2>&1 rc-update add x-ui > /dev/null 2>&1 @@ -1004,8 +1107,7 @@ update_x-ui() { else if [ -f "x-ui.service" ]; then echo -e "${green}Installing systemd unit...${plain}" - cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1 - if [[ $? -ne 0 ]]; then + if ! _install_xui_service_unit "x-ui.service" "false"; then echo -e "${red}Failed to copy x-ui.service${plain}" exit 1 fi @@ -1015,8 +1117,7 @@ update_x-ui() { ubuntu | debian | armbian) if [ -f "x-ui.service.debian" ]; then echo -e "${green}Installing debian-like systemd unit...${plain}" - cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then + if _install_xui_service_unit "x-ui.service.debian" "false"; then service_installed=true fi fi @@ -1024,8 +1125,7 @@ update_x-ui() { arch | manjaro | parch) if [ -f "x-ui.service.arch" ]; then echo -e "${green}Installing arch-like systemd unit...${plain}" - cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then + if _install_xui_service_unit "x-ui.service.arch" "false"; then service_installed=true fi fi @@ -1033,8 +1133,7 @@ update_x-ui() { *) if [ -f "x-ui.service.rhel" ]; then echo -e "${green}Installing rhel-like systemd unit...${plain}" - cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1 - if [[ $? -eq 0 ]]; then + if _install_xui_service_unit "x-ui.service.rhel" "false"; then service_installed=true fi fi @@ -1046,17 +1145,17 @@ update_x-ui() { echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}" case "${release}" in ubuntu | debian | armbian) - ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1 + service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian" ;; arch | manjaro | parch) - ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1 + service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch" ;; *) - ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1 + service_unit_url="https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel" ;; esac - if [[ $? -ne 0 ]]; then + if ! _install_xui_service_unit "$service_unit_url" "true"; then echo -e "${red}Failed to install x-ui.service from GitHub${plain}" exit 1 fi diff --git a/x-ui.sh b/x-ui.sh index 5275bf2a8..1009078c8 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -173,6 +173,41 @@ update_dev() { fi } +replace_xui_script() { + local url="$1" + local use_if_modified_since="$2" + local temp_file="/usr/bin/x-ui-temp.$$" + + rm -f "$temp_file" + if [[ "$use_if_modified_since" == "true" ]]; then + curl -fLRo "$temp_file" -z /usr/bin/x-ui "$url" + else + curl -fLRo "$temp_file" "$url" + fi + if [[ $? != 0 ]]; then + rm -f "$temp_file" + return 1 + fi + + if [[ ! -s "$temp_file" ]]; then + rm -f "$temp_file" + # -z above means "not modified since /usr/bin/x-ui" rather than a + # real failure, so an empty download here is success, not an error. + [[ "$use_if_modified_since" == "true" ]] && return 0 + return 1 + fi + + mv -f "$temp_file" /usr/bin/x-ui + if [[ $? != 0 ]]; then + rm -f "$temp_file" + return 1 + fi + # The move already landed the new script; a transient chmod failure here + # shouldn't make callers think the whole replace failed. + chmod +x /usr/bin/x-ui + return 0 +} + update_menu() { echo -e "${yellow}Updating Menu${plain}" confirm "This function will update the menu to the latest changes." "y" @@ -184,11 +219,8 @@ update_menu() { return 0 fi - curl -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh - chmod +x ${xui_folder}/x-ui.sh - chmod +x /usr/bin/x-ui - - if [[ $? == 0 ]]; then + if replace_xui_script "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh" "false"; then + chmod +x ${xui_folder}/x-ui.sh echo -e "${green}Update successful. The panel has automatically restarted.${plain}" exit 0 else @@ -804,14 +836,12 @@ enable_bbr() { } update_shell() { - curl -fLRo /usr/bin/x-ui -z /usr/bin/x-ui https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh - if [[ $? != 0 ]]; then - echo "" - LOGE "Failed to download script, Please check whether the machine can connect Github" + if replace_xui_script "https://github.com/MHSanaei/3x-ui/raw/main/x-ui.sh" "true"; then + LOGI "Upgrade script succeeded, Please rerun the script" before_show_menu else - chmod +x /usr/bin/x-ui - LOGI "Upgrade script succeeded, Please rerun the script" + echo "" + LOGE "Failed to download script, Please check whether the machine can connect Github" before_show_menu fi } @@ -1198,22 +1228,43 @@ update_geofiles() { dat_files=(geoip_RU geosite_RU) dat_source="runetfreedom/russia-v2ray-rules-dat" ;; + *) + echo -e "${red}update_geofiles: unknown dataset '${1}'${plain}" + return 1 + ;; esac local failed=0 http_code for dat in "${dat_files[@]}"; do # Remove suffix for remote filename (e.g., geoip_IR -> geoip) remote_file="${dat%%_*}" - # -z skips the download (server answers 304) when the local copy is already current - http_code=$(curl -sSfLRo ${xui_folder}/bin/${dat}.dat -z ${xui_folder}/bin/${dat}.dat -w '%{http_code}' \ + local dest="${xui_folder}/bin/${dat}.dat" + local temp_file="${dest}.tmp.$$" + rm -f "$temp_file" + # -z (against the live file, not the temp file) skips the download + # (server answers 304) when the local copy is already current. + http_code=$(curl -sSfLRo "$temp_file" -z "$dest" -w '%{http_code}' \ https://github.com/${dat_source}/releases/latest/download/${remote_file}.dat) if [[ $? -ne 0 ]]; then echo -e "${red}${dat}.dat: download failed${plain}" + rm -f "$temp_file" failed=1 elif [[ "$http_code" == "304" ]]; then echo -e "${dat}.dat: already up to date" + rm -f "$temp_file" + elif [[ ! -s "$temp_file" ]]; then + echo -e "${red}${dat}.dat: downloaded file is empty${plain}" + rm -f "$temp_file" + failed=1 else - echo -e "${green}${dat}.dat: updated${plain}" - geo_updated=1 + mv -f "$temp_file" "$dest" + if [[ $? -ne 0 ]]; then + echo -e "${red}${dat}.dat: failed to install${plain}" + rm -f "$temp_file" + failed=1 + else + echo -e "${green}${dat}.dat: updated${plain}" + geo_updated=1 + fi fi done return $failed