Scope B (Windows agent): - probe.go split into shared core + probe_linux.go (verbatim Linux sampler, build-tagged) + probe_windows.go (Win32 sampler: GetSystemTimes CPU diff, GlobalMemoryStatusEx, fixed-drive df totals, GetIfTable2 counted set, MachineGuid boot identity, registry facts; load and TCP/UDP counts stay zero - x/sys v0.47.0 exports no wrapper) - update intents are acknowledged and skipped on Windows (the release assets are Linux binaries; Windows nodes update via install-agent.ps1) - cmd/xui-agent-windows: SCM service host (svc.Run + mgr install/ uninstall/start/stop) mirroring main.go's agent lifecycle wiring - install-agent.ps1: downloads the exe, writes probeOnly agent.json, registers and starts the service, rerun-safe Scope C (version display): - config.GetAgentDisplayVersion (and the master getter) compose from the bare number so a V-form injection no longer doubles the letter (AV2.1.0 -> A2.1.0) - node list view repairs stored AV-prefixed reports at assembly so deployed agents read uniformly without an update Scope D (production fix): - share-link reality sni: when the inbound has a sing-box fragment, sni is the fragment's tls.server_name - the only name the sing-box REALITY server serves. The previous rule picked a uniformly random serverNames entry per generation, which xray tolerated but sing-box refuses as an invalid connection. sid keeps its random pick: the converter carries the whole shortIds list into the fragment, so every listed sid is served.
211 lines
7.3 KiB
Go
211 lines
7.3 KiB
Go
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())
|
|
}
|
|
|
|
// selfUpdateSupported reports whether this platform can consume a
|
|
// master update intent. Windows agents are the standalone
|
|
// xui-agent-windows binary (see cmd/xui-agent-windows), serviced by
|
|
// re-running install-agent.ps1: the release assets an intent points
|
|
// at are the panel's Linux binaries, so an intent delivered to a
|
|
// Windows node is acknowledged and skipped, never downloaded.
|
|
// A package variable so tests can exercise the skip on any OS.
|
|
var selfUpdateSupported = runtime.GOOS != "windows"
|
|
|
|
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
|
|
}
|
|
if !selfUpdateSupported {
|
|
// Windows (see selfUpdateSupported): log once per intent and
|
|
// mark it handled so the poll does not re-log it every cycle.
|
|
// No report is filed — the master-side intent simply ages out,
|
|
// and the node updates by re-running install-agent.ps1.
|
|
a.updateMu.Lock()
|
|
if intent.RequestedAt != a.updateHandled {
|
|
a.updateHandled = intent.RequestedAt
|
|
logger.Infof("agent update to %s offered but skipped on %s: Windows agents update by re-running install-agent.ps1", intent.Target, runtime.GOOS)
|
|
}
|
|
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()
|
|
}
|