diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8e8ceb7..20dd4f2 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -75,6 +75,15 @@ type Agent struct { xrayVer string xrayVerFor string // binary path the cached version was read from xrayVerMod time.Time + + // One-click update bookkeeping (item 20, see update.go). + updateMu sync.Mutex + updateRunning bool + updateHandled int64 + updateFailedReq int64 + updateFailedAt time.Time + updateReport *agentUpdateStatus + exePathOverride string } // New creates a new agent instance @@ -272,6 +281,9 @@ func (a *Agent) sendHeartbeat() error { "timestamp": time.Now().Unix(), "telemetry": a.collectTelemetry(), } + if rep := a.currentUpdateReport(); rep != nil { + payload["updateStatus"] = rep + } body, _ := json.Marshal(payload) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { @@ -312,13 +324,18 @@ func (a *Agent) fetchAndApplyConfig() error { } var result struct { - ConfigHash string `json:"config_hash"` - XrayConfig json.RawMessage `json:"xray_config"` + ConfigHash string `json:"config_hash"` + XrayConfig json.RawMessage `json:"xray_config"` + AgentUpdate *updateIntent `json:"agent_update"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return err } + // One-click update intents ride this poll (item 20); handle them even + // when the xray config itself is unchanged. + a.maybeHandleUpdate(result.AgentUpdate) + // Skip if config hasn't changed if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" { return nil diff --git a/internal/agent/update.go b/internal/agent/update.go new file mode 100644 index 0000000..45560de --- /dev/null +++ b/internal/agent/update.go @@ -0,0 +1,188 @@ +package agent + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/selfupdate" +) + +// One-click agent update (item 20). The master records an intent on the +// agent row; the intent rides the existing config poll (no new channel). +// The agent downloads the release binary for its architecture from the +// master's release base, verifies it against the published .sha256 +// sidecar, swaps it into place keeping the old binary as .bak, and +// re-execs itself. Any failure before the swap leaves the old binary +// running untouched; the outcome is reported on heartbeats, and the new +// A-series version appearing at the master is the success signal. + +// updateIntent is the master's update request as delivered on the config +// response. +type updateIntent struct { + RequestedAt int64 `json:"requested_at"` + Target string `json:"target"` // A-form display target, e.g. "A1.0.1" + Tag string `json:"tag"` // release tag, e.g. "V1.0.1" + BaseURL string `json:"base_url"` // release download base (trailing slash) +} + +// agentUpdateStatus mirrors the master's heartbeat updateStatus object. +type agentUpdateStatus struct { + RequestedAt int64 `json:"requestedAt"` + State string `json:"state"` // downloading | swapped | failed | current + Error string `json:"error,omitempty"` +} + +// updateRetryBackoff suppresses immediate re-downloads after a failure for +// the same intent (the master clears failed intents on report, so this +// only guards a master that keeps re-offering the same requested_at). +const updateRetryBackoff = 10 * time.Minute + +// execSelf replaces the current process image with the binary at path. +// A package variable so tests can stub the restart. +var execSelf = func(path string) error { + return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ()) +} + +func (a *Agent) setUpdateReport(st agentUpdateStatus) { + a.updateMu.Lock() + cp := st + a.updateReport = &cp + a.updateMu.Unlock() +} + +func (a *Agent) currentUpdateReport() *agentUpdateStatus { + a.updateMu.Lock() + defer a.updateMu.Unlock() + if a.updateReport == nil { + return nil + } + cp := *a.updateReport + return &cp +} + +// maybeHandleUpdate is called on every config poll with the intent the +// master attached (nil when none). Terminal reports are dropped once the +// master withdraws the intent, so heartbeats stop repeating them. +func (a *Agent) maybeHandleUpdate(intent *updateIntent) { + if intent == nil { + a.updateMu.Lock() + if a.updateReport != nil && + (a.updateReport.State == "failed" || a.updateReport.State == "current") { + a.updateReport = nil + } + a.updateMu.Unlock() + return + } + a.updateMu.Lock() + if a.updateRunning || intent.RequestedAt == a.updateHandled || + (intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) { + a.updateMu.Unlock() + return + } + a.updateRunning = true + a.updateMu.Unlock() + go a.runUpdate(*intent) +} + +// runUpdate executes one intent end to end. It runs synchronously in its +// own goroutine (spawned by maybeHandleUpdate) — tests call it directly. +func (a *Agent) runUpdate(intent updateIntent) { + defer func() { + a.updateMu.Lock() + a.updateRunning = false + a.updateMu.Unlock() + }() + a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "downloading"}) + + fail := func(err error) { + a.updateMu.Lock() + a.updateHandled = intent.RequestedAt + a.updateFailedReq = intent.RequestedAt + a.updateFailedAt = time.Now() + a.updateMu.Unlock() + a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "failed", Error: err.Error()}) + logger.Warningf("agent update to %s failed: %v (old version keeps running)", intent.Target, err) + } + + // Already at (or beyond) the target — e.g. the intent is re-delivered + // after the swap-restart before the master closes it out. Report + // "current" so the master clears the intent without a download. + if cmp, ok := config.CompareDisplayVersion(config.GetAgentDisplayVersion(), intent.Target); ok && cmp >= 0 { + a.updateMu.Lock() + a.updateHandled = intent.RequestedAt + a.updateMu.Unlock() + a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "current"}) + return + } + + exePath, err := a.selfExePath() + if err != nil { + fail(fmt.Errorf("locate own binary: %w", err)) + return + } + asset, err := selfupdate.AssetName(runtime.GOARCH) + if err != nil { + fail(err) + return + } + base := intent.BaseURL + if base == "" { + base = selfupdate.BaseURL() + } + base = strings.TrimSuffix(base, "/") + "/" + intent.Tag + "/" + + tmp := filepath.Join(filepath.Dir(exePath), fmt.Sprintf(".x-ui-update-%d", intent.RequestedAt)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + var transport http.RoundTripper + if a.httpClient != nil { + transport = a.httpClient.Transport + } + client := &http.Client{Transport: transport, Timeout: 10 * time.Minute} + if err := selfupdate.DownloadVerified(ctx, client, base+asset, base+asset+".sha256", tmp); err != nil { + _ = os.Remove(tmp) + fail(err) + return + } + if _, err := selfupdate.SwapExecutable(exePath, tmp); err != nil { + _ = os.Remove(tmp) + fail(err) + return + } + + a.updateMu.Lock() + a.updateHandled = intent.RequestedAt + a.updateMu.Unlock() + a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "swapped"}) + logger.Infof("agent update to %s downloaded, verified and swapped in; restarting", intent.Target) + // Best effort: flush the "swapped" report before the image is replaced + // (success is ultimately proven by the new version's telemetry). + _ = a.sendHeartbeat() + a.stopXray() + if err := execSelf(exePath); err != nil { + // The exec failed before replacing the image: roll the old binary + // back into place and keep running it. + if rbErr := selfupdate.RollbackExecutable(exePath); rbErr != nil { + logger.Warningf("agent update rollback failed: %v", rbErr) + } + fail(fmt.Errorf("restart into new binary: %w", err)) + } +} + +// selfExePath resolves the agent's own binary path (os.Executable unless a +// test overrode it). +func (a *Agent) selfExePath() (string, error) { + if a.exePathOverride != "" { + return a.exePathOverride, nil + } + return selfupdate.ExecutablePath() +} diff --git a/internal/agent/update_test.go b/internal/agent/update_test.go new file mode 100644 index 0000000..90706d9 --- /dev/null +++ b/internal/agent/update_test.go @@ -0,0 +1,166 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/selfupdate" +) + +// Item 20: the agent consumes the master's update intent, downloads the +// release asset for its architecture, verifies the sha256 sidecar, swaps +// the binary keeping a .bak, and re-execs. Failures must leave the old +// binary running and produce a "failed" report. + +func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server { + t.Helper() + asset, err := selfupdate.AssetName(runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(payload) + if !goodSum { + sum = sha256.Sum256([]byte("something else")) + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits != nil { + hits.Add(1) + } + switch { + case strings.HasSuffix(r.URL.Path, asset+".sha256"): + _, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + " " + asset + "\n")) + case strings.HasSuffix(r.URL.Path, asset): + _, _ = w.Write(payload) + default: + http.NotFound(w, r) + } + })) +} + +func newUpdateTestAgent(t *testing.T, masterURL string) (*Agent, string) { + t.Helper() + dir := t.TempDir() + exe := filepath.Join(dir, "xui-agent") + if err := os.WriteFile(exe, []byte("old-binary"), 0o755); err != nil { + t.Fatal(err) + } + a := &Agent{ + config: &Config{MasterURL: masterURL, Token: "tok", NodeName: "n"}, + httpClient: http.DefaultClient, + exePathOverride: exe, + } + return a, exe +} + +func stubExec(t *testing.T, err error) *atomic.Int32 { + t.Helper() + calls := &atomic.Int32{} + orig := execSelf + execSelf = func(string) error { + calls.Add(1) + return err + } + t.Cleanup(func() { execSelf = orig }) + return calls +} + +func TestAgentUpdateSwapsAndRestarts(t *testing.T) { + srv := fakeReleaseServer(t, []byte("new-binary"), true, nil) + defer srv.Close() + a, exe := newUpdateTestAgent(t, srv.URL) + execCalls := stubExec(t, nil) + + a.runUpdate(updateIntent{RequestedAt: 42, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"}) + + got, _ := os.ReadFile(exe) + if string(got) != "new-binary" { + t.Fatalf("exe after update = %q, want new-binary", got) + } + bak, _ := os.ReadFile(exe + ".bak") + if string(bak) != "old-binary" { + t.Fatalf(".bak = %q, want old-binary", bak) + } + if execCalls.Load() != 1 { + t.Fatalf("execSelf calls = %d, want 1", execCalls.Load()) + } + if rep := a.currentUpdateReport(); rep == nil || rep.State != "swapped" { + t.Fatalf("report = %+v, want swapped", rep) + } +} + +func TestAgentUpdateBadChecksumKeepsOldBinary(t *testing.T) { + srv := fakeReleaseServer(t, []byte("new-binary"), false, nil) + defer srv.Close() + a, exe := newUpdateTestAgent(t, srv.URL) + execCalls := stubExec(t, nil) + + a.runUpdate(updateIntent{RequestedAt: 43, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"}) + + got, _ := os.ReadFile(exe) + if string(got) != "old-binary" { + t.Fatalf("exe after failed update = %q, want old-binary", got) + } + if _, err := os.Stat(exe + ".bak"); !os.IsNotExist(err) { + t.Fatal("no .bak may be created when verification fails") + } + if execCalls.Load() != 0 { + t.Fatal("execSelf must not run when verification fails") + } + rep := a.currentUpdateReport() + if rep == nil || rep.State != "failed" || !strings.Contains(rep.Error, "sha256 mismatch") { + t.Fatalf("report = %+v, want failed with sha256 mismatch", rep) + } +} + +func TestAgentUpdateAlreadyCurrentSkipsDownload(t *testing.T) { + var hits atomic.Int32 + srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits) + defer srv.Close() + a, exe := newUpdateTestAgent(t, srv.URL) + stubExec(t, nil) + + // The test binary's display version defaults to A1.0.0. + a.runUpdate(updateIntent{RequestedAt: 44, Target: "A1.0.0", Tag: "V1.0.0", BaseURL: srv.URL + "/"}) + + if hits.Load() != 0 { + t.Fatalf("release server hit %d times for an already-current agent", hits.Load()) + } + got, _ := os.ReadFile(exe) + if string(got) != "old-binary" { + t.Fatalf("exe = %q, want untouched old-binary", got) + } + if rep := a.currentUpdateReport(); rep == nil || rep.State != "current" { + t.Fatalf("report = %+v, want current", rep) + } +} + +func TestAgentUpdateExecFailureRollsBack(t *testing.T) { + srv := fakeReleaseServer(t, []byte("new-binary"), true, nil) + defer srv.Close() + a, exe := newUpdateTestAgent(t, srv.URL) + stubExec(t, errExecBoom) + + a.runUpdate(updateIntent{RequestedAt: 45, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"}) + + got, _ := os.ReadFile(exe) + if string(got) != "old-binary" { + t.Fatalf("exe after exec failure = %q, want rolled-back old-binary", got) + } + if rep := a.currentUpdateReport(); rep == nil || rep.State != "failed" { + t.Fatalf("report = %+v, want failed", rep) + } +} + +var errExecBoom = &execBoomError{} + +type execBoomError struct{} + +func (*execBoomError) Error() string { return "exec boom" } diff --git a/internal/database/model/agent_node.go b/internal/database/model/agent_node.go index 0adbcca..f95363d 100644 --- a/internal/database/model/agent_node.go +++ b/internal/database/model/agent_node.go @@ -47,4 +47,17 @@ type AgentNode struct { // TelemetryAt is the unix-milli time of the last heartbeat that carried // a telemetry object. TelemetryAt int64 `json:"telemetryAt"` + + // One-click update (item 20): the master records an update intent here; + // the agent picks it up from its config poll, downloads + verifies + + // swaps its binary, restarts itself and reports the outcome back on + // the heartbeat. UpdateRequestedAt == 0 means no intent is pending. + UpdateRequestedAt int64 `json:"updateRequestedAt"` + UpdateTarget string `json:"updateTarget"` // A-form display target, e.g. "A1.0.1" + UpdateTag string `json:"updateTag"` // release tag to download, e.g. "V1.0.1" + // UpdateState: "" | pending | downloading | swapped | failed | + // current | success (success = new A version observed by the master). + UpdateState string `json:"updateState"` + UpdateError string `json:"updateError"` + UpdateStateAt int64 `json:"updateStateAt"` } diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..c7d10f5 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,212 @@ +// Package selfupdate carries the shared mechanics of the fork's one-click +// online update (item 20): release coordinates, sha256-verified download +// and atomic executable replacement with a .bak rollback copy. It is used +// by the agent (self-update on master intent) and by the master panel +// (native self-update on systemd hosts). No new inbound ports anywhere: +// everything rides the existing HTTPS endpoints. +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// DefaultBaseURL is the release download base of the fork's own Gitea. +// A release tag directory hangs directly below it: +// /x-ui-linux- plus a ".sha256" sidecar. +const DefaultBaseURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/" + +// LatestReleaseAPI is the Gitea API endpoint for the newest release of the +// fork's repository (the same source the panel update check reads). +const LatestReleaseAPI = "https://git.881218.xyz/api/v1/repos/rogue/3x-ui-agent/releases/latest" + +// BaseURLEnv overrides the release base (mirrors, tests). Read at call +// time so a process never caches a stale override. +const BaseURLEnv = "XUI_RELEASE_BASE_URL" + +// BaseURL returns the effective release download base, always with a +// trailing slash. +func BaseURL() string { + if v := strings.TrimSpace(os.Getenv(BaseURLEnv)); v != "" { + return strings.TrimSuffix(v, "/") + "/" + } + return DefaultBaseURL +} + +// AssetName maps a GOARCH to the release asset name published for it. +func AssetName(goarch string) (string, error) { + switch goarch { + case "amd64": + return "x-ui-linux-amd64", nil + case "arm64": + return "x-ui-linux-arm64", nil + default: + return "", fmt.Errorf("no release asset for architecture %q", goarch) + } +} + +// FetchLatestReleaseTag returns the tag_name of the newest release. +func FetchLatestReleaseTag(ctx context.Context, client *http.Client) (string, error) { + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, LatestReleaseAPI, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("release lookup: HTTP %d", resp.StatusCode) + } + var body struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return "", err + } + if strings.TrimSpace(body.TagName) == "" { + return "", fmt.Errorf("release lookup: empty tag_name") + } + return strings.TrimSpace(body.TagName), nil +} + +// ParseSHA256Sidecar extracts the digest from a sha256sum-style sidecar +// file (" " or a bare "", possibly with trailing lines). +func ParseSHA256Sidecar(data []byte) (string, bool) { + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return "", false + } + sum := strings.ToLower(fields[0]) + if len(sum) != sha256.Size*2 { + return "", false + } + if _, err := hex.DecodeString(sum); err != nil { + return "", false + } + return sum, true +} + +// maxAssetBytes caps a single asset download (release binaries are ~55MB; +// the cap only guards against a hostile or broken source looping forever). +const maxAssetBytes = 1 << 30 + +// DownloadVerified streams fileURL to destPath while hashing it, then +// compares the digest against the sha256 sidecar at sumURL. On any failure +// the partial destination is removed and the error explains which step +// broke, so the caller can keep running the old binary untouched. +func DownloadVerified(ctx context.Context, client *http.Client, fileURL, sumURL, destPath string) error { + if client == nil { + client = http.DefaultClient + } + sumReq, err := http.NewRequestWithContext(ctx, http.MethodGet, sumURL, nil) + if err != nil { + return err + } + sumResp, err := client.Do(sumReq) + if err != nil { + return fmt.Errorf("fetch checksum: %w", err) + } + sumBody, err := io.ReadAll(io.LimitReader(sumResp.Body, 4096)) + sumResp.Body.Close() + if err != nil { + return fmt.Errorf("read checksum: %w", err) + } + if sumResp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch checksum: HTTP %d", sumResp.StatusCode) + } + want, ok := ParseSHA256Sidecar(sumBody) + if !ok { + return fmt.Errorf("checksum file has no valid sha256 digest") + } + + fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return err + } + fileResp, err := client.Do(fileReq) + if err != nil { + return fmt.Errorf("download asset: %w", err) + } + defer fileResp.Body.Close() + if fileResp.StatusCode != http.StatusOK { + return fmt.Errorf("download asset: HTTP %d", fileResp.StatusCode) + } + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return fmt.Errorf("create temp binary: %w", err) + } + hasher := sha256.New() + _, copyErr := io.Copy(io.MultiWriter(out, hasher), io.LimitReader(fileResp.Body, maxAssetBytes+1)) + closeErr := out.Close() + if copyErr != nil { + _ = os.Remove(destPath) + return fmt.Errorf("download asset: %w", copyErr) + } + if closeErr != nil { + _ = os.Remove(destPath) + return fmt.Errorf("write temp binary: %w", closeErr) + } + if got := hex.EncodeToString(hasher.Sum(nil)); got != want { + _ = os.Remove(destPath) + return fmt.Errorf("sha256 mismatch: got %s, want %s", got, want) + } + return nil +} + +// SwapExecutable atomically replaces the executable at exePath with the +// file at newPath, keeping the previous binary at exePath+".bak" for +// rollback. Both paths must live on the same filesystem (the caller +// downloads into the executable's directory). +func SwapExecutable(exePath, newPath string) (backupPath string, err error) { + backupPath = exePath + ".bak" + if err := os.Chmod(newPath, 0o755); err != nil { + return "", fmt.Errorf("chmod new binary: %w", err) + } + _ = os.Remove(backupPath) + if err := os.Rename(exePath, backupPath); err != nil { + return "", fmt.Errorf("backup current binary: %w", err) + } + if err := os.Rename(newPath, exePath); err != nil { + // Best effort: put the old binary back so the service keeps a + // runnable executable at the expected path. + _ = os.Rename(backupPath, exePath) + return "", fmt.Errorf("install new binary: %w", err) + } + return backupPath, nil +} + +// RollbackExecutable restores the .bak copy over exePath (used when the +// post-swap restart fails before the new binary ever runs). +func RollbackExecutable(exePath string) error { + backupPath := exePath + ".bak" + if _, err := os.Stat(backupPath); err != nil { + return fmt.Errorf("no backup at %s: %w", backupPath, err) + } + return os.Rename(backupPath, exePath) +} + +// ExecutablePath resolves the running binary's real path. +func ExecutablePath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + return resolved, nil + } + return exe, nil +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..1667517 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,130 @@ +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseSHA256Sidecar(t *testing.T) { + sum := sha256.Sum256([]byte("payload")) + hexSum := hex.EncodeToString(sum[:]) + cases := []struct { + in string + want string + ok bool + }{ + {hexSum + " x-ui-linux-amd64\n", hexSum, true}, + {hexSum + "\n", hexSum, true}, + {strings.ToUpper(hexSum) + " *x-ui-linux-amd64", hexSum, true}, + {"", "", false}, + {"not-a-digest file", "", false}, + {hexSum[:40] + " file", "", false}, + } + for _, tc := range cases { + got, ok := ParseSHA256Sidecar([]byte(tc.in)) + if got != tc.want || ok != tc.ok { + t.Fatalf("ParseSHA256Sidecar(%q) = %q, %v; want %q, %v", tc.in, got, ok, tc.want, tc.ok) + } + } +} + +func TestAssetName(t *testing.T) { + if name, err := AssetName("amd64"); err != nil || name != "x-ui-linux-amd64" { + t.Fatalf("amd64: %q, %v", name, err) + } + if name, err := AssetName("arm64"); err != nil || name != "x-ui-linux-arm64" { + t.Fatalf("arm64: %q, %v", name, err) + } + if _, err := AssetName("riscv64"); err == nil { + t.Fatal("unknown arch must error") + } +} + +func TestSwapExecutableKeepsBackup(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "xui-agent") + if err := os.WriteFile(exe, []byte("old-binary"), 0o755); err != nil { + t.Fatal(err) + } + next := filepath.Join(dir, ".x-ui-update-1") + if err := os.WriteFile(next, []byte("new-binary"), 0o644); err != nil { + t.Fatal(err) + } + bak, err := SwapExecutable(exe, next) + if err != nil { + t.Fatalf("SwapExecutable: %v", err) + } + if bak != exe+".bak" { + t.Fatalf("backup path = %q", bak) + } + got, _ := os.ReadFile(exe) + if string(got) != "new-binary" { + t.Fatalf("exe content = %q, want new-binary", got) + } + old, _ := os.ReadFile(bak) + if string(old) != "old-binary" { + t.Fatalf("bak content = %q, want old-binary", old) + } + info, _ := os.Stat(exe) + if info.Mode().Perm()&0o100 == 0 { + t.Fatal("new binary is not executable") + } + // Rollback restores the old binary. + if err := RollbackExecutable(exe); err != nil { + t.Fatalf("RollbackExecutable: %v", err) + } + got, _ = os.ReadFile(exe) + if string(got) != "old-binary" { + t.Fatalf("after rollback exe = %q", got) + } +} + +func TestDownloadVerified(t *testing.T) { + payload := []byte("fake-release-binary") + sum := sha256.Sum256(payload) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, ".sha256"): + _, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + " x-ui-linux-amd64\n")) + default: + _, _ = w.Write(payload) + } + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "dl") + if err := DownloadVerified(context.Background(), srv.Client(), srv.URL+"/x-ui-linux-amd64", srv.URL+"/x-ui-linux-amd64.sha256", dest); err != nil { + t.Fatalf("DownloadVerified: %v", err) + } + got, _ := os.ReadFile(dest) + if string(got) != string(payload) { + t.Fatalf("downloaded content = %q", got) + } +} + +func TestDownloadVerifiedRejectsBadChecksum(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".sha256") { + _, _ = w.Write([]byte(strings.Repeat("0", 64) + " x-ui-linux-amd64\n")) + return + } + _, _ = w.Write([]byte("tampered")) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "dl") + err := DownloadVerified(context.Background(), srv.Client(), srv.URL+"/a", srv.URL+"/a.sha256", dest) + if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") { + t.Fatalf("want sha256 mismatch error, got %v", err) + } + if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { + t.Fatal("partial download must be removed on mismatch") + } +} diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index f5d5aa2..fb9be6d 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -9,9 +9,11 @@ import ( "strconv" "time" + "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/selfupdate" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -177,6 +179,73 @@ func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) { _ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error } +// heartbeatUpdateStatus is the agent's report about a one-click update +// intent (item 20), piggybacked on the heartbeat like telemetry. +type heartbeatUpdateStatus struct { + RequestedAt int64 `json:"requestedAt"` + State string `json:"state"` + Error string `json:"error"` +} + +// applyAgentUpdateStatus folds an agent's update report into the stored +// intent. Reports for a superseded intent (requested_at mismatch while an +// intent is pending) are ignored. Terminal reports (failed / current) +// clear the intent so the config poll stops re-offering it; the recorded +// state stays visible on the node row. +func (a *AgentController) applyAgentUpdateStatus(id uint, st *heartbeatUpdateStatus) { + db := database.GetDB() + var row model.AgentNode + if err := db.First(&row, id).Error; err != nil { + return + } + if row.UpdateRequestedAt == 0 || st.RequestedAt != row.UpdateRequestedAt { + return + } + updates := map[string]any{"update_state_at": time.Now().UnixMilli()} + switch st.State { + case "downloading", "swapped": + updates["update_state"] = st.State + updates["update_error"] = "" + case "failed": + updates["update_state"] = "failed" + updates["update_error"] = st.Error + updates["update_requested_at"] = int64(0) + case "current": + updates["update_state"] = "current" + updates["update_error"] = "" + updates["update_requested_at"] = int64(0) + default: + return + } + _ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error +} + +// confirmAgentUpdate marks a pending intent successful once the agent +// reports an A-series version at or beyond the intent's target — the new +// version appearing is the end-to-end success signal of the update. +func (a *AgentController) confirmAgentUpdate(id uint, reportedVersion string) { + if reportedVersion == "" { + return + } + db := database.GetDB() + var row model.AgentNode + if err := db.First(&row, id).Error; err != nil { + return + } + if row.UpdateRequestedAt == 0 || row.UpdateTarget == "" { + return + } + if cmp, ok := config.CompareDisplayVersion(reportedVersion, row.UpdateTarget); !ok || cmp < 0 { + return + } + _ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(map[string]any{ + "update_state": "success", + "update_error": "", + "update_requested_at": int64(0), + "update_state_at": time.Now().UnixMilli(), + }).Error +} + func (a *AgentController) heartbeat(c *gin.Context) { agent, ok := a.findAgent(bearerToken(c)) if !ok { @@ -186,13 +255,22 @@ func (a *AgentController) heartbeat(c *gin.Context) { // Telemetry is optional and best-effort: a body that fails to parse // (or no body at all) must never fail the heartbeat itself. var req struct { - Telemetry *heartbeatTelemetry `json:"telemetry"` + Telemetry *heartbeatTelemetry `json:"telemetry"` + UpdateStatus *heartbeatUpdateStatus `json:"updateStatus"` } _ = c.ShouldBindJSON(&req) a.touchAgent(uint(agent.Id), "") + reportedVersion := agent.AgentVersion if req.Telemetry != nil { a.updateAgentTelemetry(uint(agent.Id), req.Telemetry) + if req.Telemetry.AgentVersion != nil && *req.Telemetry.AgentVersion != "" { + reportedVersion = *req.Telemetry.AgentVersion + } } + if req.UpdateStatus != nil { + a.applyAgentUpdateStatus(uint(agent.Id), req.UpdateStatus) + } + a.confirmAgentUpdate(uint(agent.Id), reportedVersion) jsonObj(c, gin.H{"message": "ok"}, nil) } @@ -218,10 +296,22 @@ func (a *AgentController) getConfig(c *gin.Context) { return } sum := sha256.Sum256(raw) - c.JSON(200, gin.H{ + payload := gin.H{ "config_hash": hex.EncodeToString(sum[:]), "xray_config": json.RawMessage(raw), - }) + } + // Item 20: a pending one-click update intent rides along on the config + // poll (no new channel — the agent already polls this endpoint). + if agent.UpdateRequestedAt > 0 && + (agent.UpdateState == "pending" || agent.UpdateState == "downloading") { + payload["agent_update"] = gin.H{ + "requested_at": agent.UpdateRequestedAt, + "target": agent.UpdateTarget, + "tag": agent.UpdateTag, + "base_url": selfupdate.BaseURL(), + } + } + c.JSON(200, payload) } type agentTrafficReport struct { diff --git a/internal/web/controller/agent_update_status_test.go b/internal/web/controller/agent_update_status_test.go new file mode 100644 index 0000000..6600dfd --- /dev/null +++ b/internal/web/controller/agent_update_status_test.go @@ -0,0 +1,125 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Item 20 (master side): a pending update intent rides the agent's config +// poll; outcome reports come back on heartbeats; a heartbeat whose +// telemetry reaches the target version closes the intent as successful. + +const updToken = "update-status-token-0000000000000000000001" + +func setupUpdateStatusTest(t *testing.T) (*gin.Engine, *model.AgentNode) { + t.Helper() + gin.SetMode(gin.TestMode) + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + db := database.GetDB() + + node := &model.Node{Name: "upd-node", Address: "10.0.9.3"} + if err := db.Create(node).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + agent := &model.AgentNode{ + TokenHash: hashAgentToken(updToken), + TokenPrefix: updToken[:12], + Name: "upd-node", + NodeID: &node.Id, + AgentVersion: "A1.0.0", + UpdateRequestedAt: 1700000000000, + UpdateTarget: "A1.0.1", + UpdateTag: "V1.0.1", + UpdateState: "pending", + } + if err := db.Create(agent).Error; err != nil { + t.Fatalf("seed agent: %v", err) + } + engine := gin.New() + NewAgentController(engine.Group("/panel/api/agent")) + return engine, agent +} + +func postHeartbeat(t *testing.T, engine *gin.Engine, body string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+updToken) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("heartbeat: status %d", rec.Code) + } +} + +func TestConfigCarriesPendingUpdateIntent(t *testing.T) { + engine, _ := setupUpdateStatusTest(t) + req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil) + req.Header.Set("Authorization", "Bearer "+updToken) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String()) + } + var resp struct { + AgentUpdate *struct { + RequestedAt int64 `json:"requested_at"` + Target string `json:"target"` + Tag string `json:"tag"` + BaseURL string `json:"base_url"` + } `json:"agent_update"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("config response not JSON: %v", err) + } + if resp.AgentUpdate == nil { + t.Fatal("pending intent missing from config response") + } + if resp.AgentUpdate.Target != "A1.0.1" || resp.AgentUpdate.Tag != "V1.0.1" || + resp.AgentUpdate.RequestedAt != 1700000000000 || + !strings.HasSuffix(resp.AgentUpdate.BaseURL, "/") { + t.Fatalf("intent payload wrong: %+v", resp.AgentUpdate) + } +} + +func TestHeartbeatFailedReportClearsIntent(t *testing.T) { + engine, agent := setupUpdateStatusTest(t) + postHeartbeat(t, engine, `{"updateStatus":{"requestedAt":1700000000000,"state":"failed","error":"sha256 mismatch"}}`) + + row := reloadAgent(t, agent.Id) + if row.UpdateState != "failed" || row.UpdateError != "sha256 mismatch" || row.UpdateRequestedAt != 0 { + t.Fatalf("failed report not folded: %+v", row) + } + + // A stale report for the cleared intent must not resurrect it. + postHeartbeat(t, engine, `{"updateStatus":{"requestedAt":1700000000000,"state":"downloading"}}`) + row = reloadAgent(t, agent.Id) + if row.UpdateState != "failed" { + t.Fatalf("stale report changed state: %+v", row) + } +} + +func TestHeartbeatNewVersionConfirmsSuccess(t *testing.T) { + engine, agent := setupUpdateStatusTest(t) + postHeartbeat(t, engine, `{"telemetry":{"agentVersion":"A1.0.1"}}`) + + row := reloadAgent(t, agent.Id) + if row.UpdateState != "success" || row.UpdateRequestedAt != 0 { + t.Fatalf("success not confirmed: %+v", row) + } +} diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 98bcb5a..8c2b506 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -17,10 +17,12 @@ import ( "sync" "time" + "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/selfupdate" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" @@ -907,14 +909,54 @@ type NodeUpdateResult struct { Error string `json:"error,omitempty"` } +// fetchLatestReleaseTag resolves the newest release tag of the fork's own +// repository; a package variable so tests can stub the network. +var fetchLatestReleaseTag = func(ctx context.Context) (string, error) { + return selfupdate.FetchLatestReleaseTag(ctx, &http.Client{Timeout: 20 * time.Second}) +} + +// requestAgentUpdate records a one-click update intent for an agent node +// (item 20). The agent picks the intent up from its next config poll, +// downloads + verifies the release binary for the latest tag, swaps and +// restarts itself; the outcome comes back on heartbeats. The dev channel +// has no meaning for agents and is ignored. +func (s *NodeService) requestAgentUpdate(ctx context.Context, n *model.Node) error { + db := database.GetDB() + var ag model.AgentNode + if err := db.Where("node_id = ?", n.Id).Order("last_seen DESC, id DESC").First(&ag).Error; err != nil { + return errors.New("no agent bound to this node") + } + tag, err := fetchLatestReleaseTag(ctx) + if err != nil { + return fmt.Errorf("check latest release: %w", err) + } + parts, ok := config.ParseDisplayVersion(tag) + if !ok { + return fmt.Errorf("latest release tag %q is not a V-series version", tag) + } + target := fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2]) + if cmp, ok := config.CompareDisplayVersion(ag.AgentVersion, target); ok && cmp >= 0 { + return fmt.Errorf("agent already up to date (%s)", ag.AgentVersion) + } + now := time.Now().UnixMilli() + return db.Model(&model.AgentNode{}).Where("id = ?", ag.Id).Updates(map[string]any{ + "update_requested_at": now, + "update_target": target, + "update_tag": tag, + "update_state": "pending", + "update_error": "", + "update_state_at": now, + }).Error +} + // UpdatePanels triggers the official self-updater on each given node. Only // enabled, online nodes are eligible — an offline node can't be reached, so it // is reported as skipped rather than silently dropped. +// +// Agent nodes take the item-20 intent path instead (see requestAgentUpdate): +// the master cannot push to them, so "triggered" means the intent was +// recorded for the agent's next poll. func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, error) { - mgr := runtime.GetManager() - if mgr == nil { - return nil, fmt.Errorf("runtime manager unavailable") - } results := make([]NodeUpdateResult, 0, len(ids)) for _, id := range ids { n, err := s.GetById(id) @@ -928,7 +970,21 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err res.Error = "node is disabled" case n.Status != "online": res.Error = "node is offline" + case s.IsAgentManaged(id): + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + err := s.requestAgentUpdate(ctx, n) + cancel() + if err != nil { + res.Error = err.Error() + } else { + res.OK = true + } default: + mgr := runtime.GetManager() + if mgr == nil { + res.Error = "runtime manager unavailable" + break + } remote, remoteErr := mgr.RemoteFor(n) if remoteErr != nil { res.Error = remoteErr.Error() diff --git a/internal/web/service/node_agent_update_test.go b/internal/web/service/node_agent_update_test.go new file mode 100644 index 0000000..2cd2905 --- /dev/null +++ b/internal/web/service/node_agent_update_test.go @@ -0,0 +1,89 @@ +package service + +import ( + "context" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Item 20 (master side): UpdatePanels on an agent node records an update +// intent on the bound agent row instead of pushing to a remote panel. The +// intent targets the newest release tag (V-series) expressed as the +// agent's A-series display version. +func TestUpdatePanelsAgentIntent(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + origFetch := fetchLatestReleaseTag + fetchLatestReleaseTag = func(context.Context) (string, error) { return "V1.0.1", nil } + t.Cleanup(func() { fetchLatestReleaseTag = origFetch }) + + node := &model.Node{Id: 7501, Name: "agent-upd", Address: "", Enable: true, Status: "online"} + if err := db.Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + ag := &model.AgentNode{ + TokenHash: "hash-upd", Name: "agent-upd", NodeID: &node.Id, + AgentVersion: "A1.0.0", + } + if err := db.Create(ag).Error; err != nil { + t.Fatalf("create agent: %v", err) + } + + svc := &NodeService{} + results, err := svc.UpdatePanels([]int{node.Id}, false) + if err != nil { + t.Fatalf("UpdatePanels: %v", err) + } + if len(results) != 1 || !results[0].OK { + t.Fatalf("results = %+v, want one OK", results) + } + var row model.AgentNode + if err := db.First(&row, ag.Id).Error; err != nil { + t.Fatal(err) + } + if row.UpdateState != "pending" || row.UpdateTarget != "A1.0.1" || + row.UpdateTag != "V1.0.1" || row.UpdateRequestedAt == 0 { + t.Fatalf("intent not recorded: %+v", row) + } +} + +func TestUpdatePanelsAgentAlreadyCurrent(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + origFetch := fetchLatestReleaseTag + fetchLatestReleaseTag = func(context.Context) (string, error) { return "V1.0.1", nil } + t.Cleanup(func() { fetchLatestReleaseTag = origFetch }) + + node := &model.Node{Id: 7502, Name: "agent-cur", Address: "", Enable: true, Status: "online"} + if err := db.Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + ag := &model.AgentNode{ + TokenHash: "hash-cur", Name: "agent-cur", NodeID: &node.Id, + AgentVersion: "A1.0.1", + } + if err := db.Create(ag).Error; err != nil { + t.Fatalf("create agent: %v", err) + } + + svc := &NodeService{} + results, err := svc.UpdatePanels([]int{node.Id}, false) + if err != nil { + t.Fatalf("UpdatePanels: %v", err) + } + if len(results) != 1 || results[0].OK || !strings.Contains(results[0].Error, "already up to date") { + t.Fatalf("results = %+v, want not-OK already-up-to-date", results) + } + var row model.AgentNode + if err := db.First(&row, ag.Id).Error; err != nil { + t.Fatal(err) + } + if row.UpdateRequestedAt != 0 { + t.Fatalf("no intent may be recorded for a current agent: %+v", row) + } +} diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index 47732fa..60ba883 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -39,6 +39,11 @@ type NodeView struct { // traditional nodes and for agents that never reported telemetry. Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"` Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"` + // Agent one-click update bookkeeping (item 20), agent nodes only: + // the last intent's state/target and, on failure, its error. + AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"` + AgentUpdateError string `json:"agentUpdateError,omitempty" example:""` + AgentUpdateTarget string `json:"agentUpdateTarget,omitempty" example:"A1.0.1"` Guid string `json:"guid" example:"node-guid"` Status string `json:"status" example:"online"` LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` @@ -208,6 +213,9 @@ func annotateAgentTelemetry(views []*NodeView) { } else { v.PanelVersion = "agent " + ag.AgentVersion } + v.AgentUpdateState = ag.UpdateState + v.AgentUpdateError = ag.UpdateError + v.AgentUpdateTarget = ag.UpdateTarget } v.UptimeSecs = ag.UptimeSecs v.LatencyMs = ag.LatencyMs