Files
3x-ui/internal/web/service/panel/panel_test.go
T
Kuzz007 9408424959 fix(panel): forward the panel's proxy to update.sh's own downloads (#6259)
* fix(panel): route update.sh's own downloads through the resolved proxy

startUpdate already fetches update.sh itself via a proxy-aware HTTP
client (NewProxiedHTTPClient), but the process that actually runs it
never got a proxy hint of its own -- so update.sh's own curl calls to
GitHub always went direct, even when the panel has a working proxy
path configured. This matters most for the systemd-run launch path,
which doesn't inherit the caller's environment at all (only --setenv
passes through), so a systemd host with a real ambient proxy would
silently lose it for this one hop.

curl already honors https_proxy/all_proxy natively, so no changes to
update.sh itself are needed -- only the launcher needs to forward a
proxy URL into the environment it hands to that detached process.

updateProxyEnvVars() prefers an already-set ambient proxy env var
(never silently overriding an admin's own proxy config) and only
falls back to the panel's own configured panel outbound
(PanelEgressProxyURL) when nothing is set, then forwards the result
to both launch paths.

* test(panel): cover updateProxyEnvVars' ambient-proxy path

Regression test for the fix in the previous commit -- an ambient
https_proxy must reach update.sh's own downloads, not just the
panel's own outbound requests. Scoped to the ambient-env branch only,
which never touches PanelEgressProxyURL/the database.

* fix: drop the panel-outbound fallback in updateProxyEnvVars

Per review: PanelEgressProxyURL() returns a loopback SOCKS bridge
living inside the panel's own Xray child. update.sh stops that child
partway through its run (systemctl stop x-ui, no KillMode override --
the default cgroup kill takes Xray with it) and removes the service
unit, but still needs curl afterwards for x-ui.sh and sometimes the
service unit itself. With the bridge dead, those downloads fail and
update.sh exits with no service unit installed and nothing to restart
it -- a host with a panel outbound configured and no ambient proxy
would be bricked by its next update.

Keep only the ambient-env-var forwarding, which is safe (an OS-level
var, not torn down when the panel dies), and fold in three smaller
fixes: forward no_proxy/NO_PROXY too, since install_base's apt/dnf
calls honor them; stop promoting a deliberately HTTP-only http_proxy
into https_proxy/all_proxy; and drop the now-redundant re-append on
the bash fallback path, which already inherits everything via
os.Environ().
2026-08-24 03:01:56 +02:00

362 lines
11 KiB
Go

package panel
import (
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func TestIsNewerVersion(t *testing.T) {
cases := []struct {
latest string
current string
want bool
}{
{"v2.9.4", "2.9.3", true},
{"v2.10.0", "2.9.9", true},
{"v2.9.3", "2.9.3", false},
{"v2.9.2", "2.9.3", false},
{"v3.0.0", "2.9.3", true},
}
for _, tc := range cases {
if got := isNewerVersion(tc.latest, tc.current); got != tc.want {
t.Fatalf("isNewerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want)
}
}
}
func TestCompareVersionStringsRejectsUnexpectedFormats(t *testing.T) {
if _, ok := compareVersionStrings("latest", "2.9.3"); ok {
t.Fatal("expected non-semver latest tag to be rejected")
}
if _, ok := compareVersionStrings("v2.9", "2.9.3"); ok {
t.Fatal("expected short version to be rejected")
}
}
func TestShellQuote(t *testing.T) {
if got := shellQuote("/usr/bin/curl"); got != "'/usr/bin/curl'" {
t.Fatalf("unexpected quote result: %s", got)
}
if got := shellQuote("/tmp/a'b"); got != "'/tmp/a'\\''b'" {
t.Fatalf("unexpected quote result with single quote: %s", got)
}
}
// TestUpdateProxyEnvVars covers the bug this function fixes: ambient proxy
// vars must reach update.sh's systemd-run child, which inherits nothing.
func TestUpdateProxyEnvVars(t *testing.T) {
allKeys := []string{"https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY", "http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY"}
clearAll := func(t *testing.T) {
t.Helper()
for _, key := range allKeys {
t.Setenv(key, "")
}
}
t.Run("nothing set returns nil", func(t *testing.T) {
clearAll(t)
if got := updateProxyEnvVars(); got != nil {
t.Fatalf("updateProxyEnvVars() = %v, want nil", got)
}
})
t.Run("forwards each set var under its own name", func(t *testing.T) {
clearAll(t)
t.Setenv("https_proxy", "socks5://127.0.0.1:10808")
t.Setenv("no_proxy", "10.0.0.0/8,localhost")
got := updateProxyEnvVars()
want := []string{"https_proxy=socks5://127.0.0.1:10808", "no_proxy=10.0.0.0/8,localhost"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("updateProxyEnvVars() = %v, want %v", got, want)
}
})
// A deliberately HTTP-only proxy config must not silently gain HTTPS traffic.
t.Run("http_proxy is not promoted to https_proxy", func(t *testing.T) {
clearAll(t)
t.Setenv("http_proxy", "http://127.0.0.1:8080")
got := updateProxyEnvVars()
want := []string{"http_proxy=http://127.0.0.1:8080"}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("updateProxyEnvVars() = %v, want %v", got, want)
}
})
}
func TestExtractReleaseCommit(t *testing.T) {
full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
cases := []struct {
name string
release service.Release
want string
}{
{
name: "from body marker",
release: service.Release{Body: "Rolling build\n\ncommit=" + full + "\nbuilt=2026-06-24T00:00:00Z"},
want: full,
},
{
name: "body marker is case-insensitive and wins over target",
release: service.Release{Body: "COMMIT=" + full, TargetCommitish: "deadbeef"},
want: full,
},
{
name: "fallback to target commit sha",
release: service.Release{Body: "no marker here", TargetCommitish: full},
want: full,
},
{
name: "branch target is not a commit",
release: service.Release{Body: "no marker", TargetCommitish: "main"},
want: "",
},
}
for _, tc := range cases {
if got := extractReleaseCommit(&tc.release); got != tc.want {
t.Fatalf("%s: extractReleaseCommit = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestCommitsEqual(t *testing.T) {
full := "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
cases := []struct {
a, b string
want bool
}{
{"1a2b3c4d", full, true}, // injected 8-char prefix matches full release sha
{full, "1a2b3c4d", true}, // order independent
{"1A2B3C4D", full, true}, // case insensitive
{"deadbeef", full, false}, // different commit
{"", full, false}, // empty current never matches
{"1a2b3c4d", "", false}, // empty latest never matches
}
for _, tc := range cases {
if got := commitsEqual(tc.a, tc.b); got != tc.want {
t.Fatalf("commitsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
func TestShortCommit(t *testing.T) {
if got := shortCommit("1a2b3c4d5e6f7a8b"); got != "1a2b3c4d" {
t.Fatalf("shortCommit truncation = %q, want %q", got, "1a2b3c4d")
}
if got := shortCommit("abc"); got != "abc" {
t.Fatalf("shortCommit short input = %q, want %q", got, "abc")
}
}
func resetUpdateSlot(t *testing.T) {
t.Helper()
t.Cleanup(func() {
updateMu.Lock()
updateRunning = false
updateRunID = 0
updatePID = 0
updateMu.Unlock()
})
}
// writeStatusFile hand-writes the status file in the exact wire format
// update.sh itself produces (a bare printf, not Go's json.Marshal), since
// that's the real cross-language contract this package reads in production.
func writeStatusFile(t *testing.T, path string, runID int64, state string) {
t.Helper()
body := fmt.Sprintf(`{"runId":"%d","state":"%s","exitCode":0,"finishedAt":%d}`, runID, state, time.Now().Unix())
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAcquireUpdateSlot(t *testing.T) {
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
if acquireUpdateSlot(2) {
t.Fatal("second acquire while first is held: got true, want false")
}
releaseUpdateSlot()
if !acquireUpdateSlot(3) {
t.Fatal("acquire after release: got false, want true")
}
releaseUpdateSlot()
}
func TestAcquireUpdateSlotExpiresAfterStaleWindow(t *testing.T) {
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
updateMu.Lock()
updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
updateMu.Unlock()
if !acquireUpdateSlot(2) {
t.Fatal("acquire after stale window elapsed: got false, want true")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow is the regression
// test for the concurrency bug an upstream review found: past
// updateStaleAfter, the old logic freed the slot purely on elapsed time, even
// if the process it launched was still genuinely running (not crashed) --
// update.sh's own package-manager step plus several downloads can plausibly
// run long on a slow host with nothing actually wrong. Now a confirmed-alive
// PID keeps the slot held past the stale window.
func TestAcquireUpdateSlotWaitsForAliveProcessPastStaleWindow(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
}
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
recordUpdatePID(os.Getpid()) // the test process itself: guaranteed alive
updateMu.Lock()
updateStarted = time.Now().Add(-(updateStaleAfter + time.Second))
updateMu.Unlock()
if acquireUpdateSlot(2) {
t.Fatal("acquire past the stale window while the recorded PID is still alive: got true, want false")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotHardCeilingOverridesLiveness confirms the absolute
// backstop: even a confirmed-alive process can't hold the slot forever, so a
// genuinely wedged run can't lock out retries permanently.
func TestAcquireUpdateSlotHardCeilingOverridesLiveness(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("processAlive is a no-op stub on non-Linux; this test only exercises real liveness checking on Linux")
}
resetUpdateSlot(t)
if !acquireUpdateSlot(1) {
t.Fatal("first acquire: got false, want true")
}
recordUpdatePID(os.Getpid())
updateMu.Lock()
updateStarted = time.Now().Add(-(updateHardCeiling + time.Second))
updateMu.Unlock()
if !acquireUpdateSlot(2) {
t.Fatal("acquire past the hard ceiling despite a live PID: got false, want true")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotReleasesOnTerminalStatus is the regression test for the
// bug adversarial review found: a fast failure used to still lock out retries
// for the full updateStaleAfter window, because acquireUpdateSlot only looked
// at the in-memory started-at timestamp, never at the status file's own
// terminal state.
func TestAcquireUpdateSlotReleasesOnTerminalStatus(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
resetUpdateSlot(t)
path := config.GetUpdateStatusFilePath()
if !acquireUpdateSlot(111) {
t.Fatal("first acquire: got false, want true")
}
writeStatusFile(t, path, 111, updateStateFailed)
if !acquireUpdateSlot(222) {
t.Fatal("acquire after the in-flight run reported failed: got false, want true (should not wait out updateStaleAfter)")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus confirms the terminal-state
// check is scoped to the run it actually launched: a status file left behind
// by some earlier, unrelated run (different runID) must not be mistaken for
// this run finishing.
func TestAcquireUpdateSlotIgnoresStaleUnrelatedStatus(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
resetUpdateSlot(t)
path := config.GetUpdateStatusFilePath()
writeStatusFile(t, path, 999, updateStateSuccess)
if !acquireUpdateSlot(111) {
t.Fatal("first acquire: got false, want true")
}
if acquireUpdateSlot(222) {
t.Fatal("acquire while status file only reflects an unrelated older runID: got true, want false")
}
releaseUpdateSlot()
}
// TestAcquireUpdateSlotConcurrency proves the check-then-set is actually
// atomic under real concurrent access, not just correct when called
// sequentially. A prior version of this test suite only ever called
// acquireUpdateSlot from a single goroutine, so it gave no signal if the
// mutex's core promise (only one concurrent launch wins) were broken.
func TestAcquireUpdateSlotConcurrency(t *testing.T) {
resetUpdateSlot(t)
const attempts = 200
var wins atomic.Int32
var wg sync.WaitGroup
wg.Add(attempts)
for i := range attempts {
go func(runID int64) {
defer wg.Done()
if acquireUpdateSlot(runID) {
wins.Add(1)
}
}(int64(i))
}
wg.Wait()
if got := wins.Load(); got != 1 {
t.Fatalf("concurrent acquireUpdateSlot: %d of %d attempts won, want exactly 1", got, attempts)
}
releaseUpdateSlot()
}
func TestGetUpdateStatus(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
path := config.GetUpdateStatusFilePath()
svc := &PanelService{}
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
t.Fatalf("missing status file: State = %q, want %q", got.State, updateStatePending)
}
writeStatusFile(t, path, 1735689600123456789, updateStateSuccess)
got := svc.GetUpdateStatus()
if got.RunID != "1735689600123456789" {
t.Fatalf("RunID = %q, want %q (must round-trip as a decimal string, not a JSON number, or it loses precision past 2^53 in JS)", got.RunID, "1735689600123456789")
}
if got.State != updateStateSuccess {
t.Fatalf("State = %q, want %q", got.State, updateStateSuccess)
}
if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil {
t.Fatal(err)
}
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
t.Fatalf("corrupt status file: State = %q, want %q", got.State, updateStatePending)
}
writeStatusFile(t, path, 1, "some-unrecognized-state")
if got := svc.GetUpdateStatus(); got.State != updateStatePending {
t.Fatalf("unrecognized state normalizes to pending: State = %q, want %q", got.State, updateStatePending)
}
}