feat(update): 第20条 agent 一键在线更新(意向→轮询→校验换名→自重启)
- 新增 internal/selfupdate:发行坐标(基址常量+env 覆盖)、Gitea latest tag 查询、sha256 旁车校验下载、原子换名(旧二进制留 .bak、 失败可回滚),含单测 - 主控:UpdatePanels 对 agent 节点改为记录更新意向(最新 tag 的 V 系→A 系目标;已达标报错不记意向);意向经现有 GET config 轮询 捎带下发;心跳收 updateStatus 回报(failed/current 清意向留状态、 过期 requested_at 忽略),遥测版本达标自动确认 success - agent:识别意向→比自身 A 版本(已达标报 current 不下载)→按架构 下载资产+sha256 校验→换名留 .bak→刷一次心跳→stopXray→ syscall.Exec 自重启;exec 失败回滚 .bak 并报 failed;失败 10 分钟 内不重下同一意向;旧版全程保持运行 - NodeView 增 agentUpdateState/Error/Target 供节点页可观测 - 单测:selfupdate 换名/校验、agent 换名重启/坏校验保旧版/已达标 跳过/exec 失败回滚、主控意向记录/已达标跳过、config 捎带意向、 心跳 failed 清意向、心跳新版本确认 success
This commit is contained in:
@@ -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 {
|
||||
@@ -314,11 +326,16 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
var result struct {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
// <base><tag>/x-ui-linux-<arch> plus a "<asset>.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 ("<hex> <name>" or a bare "<hex>", 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -187,12 +256,21 @@ func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
// (or no body at all) must never fail the heartbeat itself.
|
||||
var req struct {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user