feat(agent): standalone Linux agent binary + agent assets in update chain

New entry cmd/xui-agent links only the agent dependency closure
(internal/agent + config/logger/selfupdate/xray/util leaves), a
drop-in replacement for the dual-mode binary's agent subcommand:
same invocation (xui-agent agent -config ...), same agent.json and
state files, same wire protocol.

selfupdate gains AgentAssetName (x-ui-agent-linux-<arch>); the
agent's one-click update now downloads the agent asset instead of
the panel binary. Pre-split agents keep fetching the panel asset —
the two forms are interchangeable at the install path.
This commit is contained in:
Muse
2026-10-23 16:40:35 +00:00
parent a81eff8c0d
commit e78cb8ccc5
5 changed files with 159 additions and 5 deletions
+6 -1
View File
@@ -151,7 +151,12 @@ func (a *Agent) runUpdate(intent updateIntent) {
fail(fmt.Errorf("locate own binary: %w", err))
return
}
asset, err := selfupdate.AssetName(runtime.GOARCH)
// Agent/panel split (phase 1): the agent downloads the standalone
// agent asset, not the panel binary. An agent build predating the
// split still asks for the panel asset (AssetName) — the two are
// interchangeable at the same path, so each generation converges
// on the form its own build requests.
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
if err != nil {
fail(err)
return
+3 -1
View File
@@ -22,7 +22,9 @@ import (
func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server {
t.Helper()
asset, err := selfupdate.AssetName(runtime.GOARCH)
// The agent requests the standalone agent asset (agent/panel
// split); the fake release only serves that name.
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
if err != nil {
t.Fatal(err)
}
+25 -3
View File
@@ -20,8 +20,10 @@ import (
)
// 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.
// A release tag directory hangs directly below it, holding the panel
// assets (<base><tag>/x-ui-linux-<arch>) and — since the agent/panel
// split — the standalone agent assets (x-ui-agent-linux-<arch>),
// each with 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
@@ -41,7 +43,9 @@ func BaseURL() string {
return DefaultBaseURL
}
// AssetName maps a GOARCH to the release asset name published for it.
// AssetName maps a GOARCH to the release asset name published for it:
// the panel (dual-mode) binary. The panel's own native self-update
// downloads this asset; pre-split agents consumed it too.
func AssetName(goarch string) (string, error) {
switch goarch {
case "amd64":
@@ -53,6 +57,24 @@ func AssetName(goarch string) (string, error) {
}
}
// AgentAssetName maps a GOARCH to the standalone agent asset published
// since the agent/panel split (phase 1). A split agent's one-click
// update downloads this asset — never the panel binary — so a node
// keeps its slim form across updates. Releases predating the split do
// not carry these assets; an intent pointing at such a tag fails the
// download and the agent keeps running its old binary (the designed
// failure mode of runUpdate).
func AgentAssetName(goarch string) (string, error) {
switch goarch {
case "amd64":
return "x-ui-agent-linux-amd64", nil
case "arm64":
return "x-ui-agent-linux-arm64", nil
default:
return "", fmt.Errorf("no agent release asset for architecture %q", goarch)
}
}
// LatestReleaseURLEnv overrides the release-lookup endpoint as a whole
// (mirrors, tests), analogous to BaseURLEnv.
const LatestReleaseURLEnv = "XUI_RELEASE_LATEST_URL"
+12
View File
@@ -47,6 +47,18 @@ func TestAssetName(t *testing.T) {
}
}
func TestAgentAssetName(t *testing.T) {
if name, err := AgentAssetName("amd64"); err != nil || name != "x-ui-agent-linux-amd64" {
t.Fatalf("amd64: %q, %v", name, err)
}
if name, err := AgentAssetName("arm64"); err != nil || name != "x-ui-agent-linux-arm64" {
t.Fatalf("arm64: %q, %v", name, err)
}
if _, err := AgentAssetName("riscv64"); err == nil {
t.Fatal("unknown arch must error")
}
}
func TestSwapExecutableKeepsBackup(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "xui-agent")