Files
3x-ui-agent/internal/agent/singbox_fetch.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

163 lines
5.9 KiB
Go

package agent
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// ITEM V2.0.1 ①: agents upgraded through the panel's one-click
// update swap only their own binary — a node installed before the
// V2 switchover can hold no sing-box core at all, and applying the
// master's sing-box config there would strand it (the switch retires
// xray first). Before any switch is attempted, a missing core is
// fetched from the same Gitea release install-agent.sh installs
// from (the NexusGate repo's dedicated sing-box-v1.13.21 release),
// self-checked, and only then does the switch proceed. Any failure
// leaves the node exactly as it was: xray keeps running, the fetch
// retries on a later poll (throttled), and the failure is visible in
// the agent log and — through the still-empty singboxVersion — on
// the master's node list.
// singboxCoreVersion is the pinned self-built core release, the
// same pin install-agent.sh carries (SINGBOX_VER).
const singboxCoreVersion = "1.13.21"
// singboxCoreBaseURL is the release download base. A package
// variable so tests can point it at a local server.
var singboxCoreBaseURL = "https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v" + singboxCoreVersion
// singboxFetchBackoff suppresses immediate re-downloads after a
// failed fetch: a node whose route to the release host is broken
// must not hammer it every config poll.
const singboxFetchBackoff = 10 * time.Minute
// singboxCoreAssetName is the release asset for this agent's
// architecture (the release publishes linux amd64/arm64 only, the
// same set install-agent.sh supports).
func singboxCoreAssetName() (string, error) {
switch runtime.GOARCH {
case "amd64", "arm64":
return "nexusgate-sing-box-linux-" + runtime.GOARCH, nil
}
return "", fmt.Errorf("no sing-box core asset for architecture %s", runtime.GOARCH)
}
// singboxCoreReady reports whether the node can run sing-box: the
// core is on disk, or a fetch just installed it. Callers on the
// config path use it as the switch gate.
func (a *Agent) singboxCoreReady() bool {
if singboxBinaryPath() != "" {
return true
}
_ = a.fetchSingboxCoreThrottled()
return singboxBinaryPath() != ""
}
// fetchSingboxCoreThrottled runs one fetch attempt unless the last
// failure is still inside the backoff window. A failed attempt is
// logged once (here, not per poll) and stays visible on the master
// through the agent's still-empty singboxVersion telemetry.
func (a *Agent) fetchSingboxCoreThrottled() error {
a.sbFetchMu.Lock()
defer a.sbFetchMu.Unlock()
if !a.sbFetchFailedAt.IsZero() && time.Since(a.sbFetchFailedAt) < singboxFetchBackoff {
return fmt.Errorf("sing-box core fetch in backoff after failure at %s", a.sbFetchFailedAt.Format(time.RFC3339))
}
if err := a.fetchSingboxCore(); err != nil {
a.sbFetchFailedAt = time.Now()
logger.Warningf("sing-box core fetch failed, staying on the current runtime (retry after backoff): %v", err)
return err
}
a.sbFetchFailedAt = time.Time{}
return nil
}
// fetchSingboxCore downloads the pinned core into the bin folder,
// self-checks it (a runnable `version`, mirroring the install
// script's closing self-check), and only then moves it into place
// and writes the install-time version record the telemetry reads.
func (a *Agent) fetchSingboxCore() error {
asset, err := singboxCoreAssetName()
if err != nil {
return err
}
binDir := config.GetBinFolderPath()
if err := os.MkdirAll(binDir, 0o755); err != nil {
return fmt.Errorf("create bin folder: %w", err)
}
dest := filepath.Join(binDir, singboxBinaryName())
tmp := dest + ".download"
_ = os.Remove(tmp)
url := strings.TrimSuffix(singboxCoreBaseURL, "/") + "/" + asset
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
var transport http.RoundTripper
if a.httpClient != nil {
transport = a.httpClient.Transport
}
client := &http.Client{Transport: transport, Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download sing-box core: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download sing-box core: status %d", resp.StatusCode)
}
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("create sing-box core download: %w", err)
}
if _, err := io.Copy(f, resp.Body); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return fmt.Errorf("download sing-box core: %w", err)
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("download sing-box core: %w", err)
}
// Self-check before anything is moved into place: the binary
// must actually run on this node (right architecture, not a
// truncated or error-page download).
vctx, vcancel := context.WithTimeout(context.Background(), 10*time.Second)
out, verr := exec.CommandContext(vctx, tmp, "version").CombinedOutput()
vcancel()
if verr != nil {
_ = os.Remove(tmp)
return fmt.Errorf("sing-box core self-check failed: %v (%s)", verr, strings.TrimSpace(string(out)))
}
if len(strings.TrimSpace(string(out))) == 0 {
_ = os.Remove(tmp)
return fmt.Errorf("sing-box core self-check failed: version produced no output")
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("install sing-box core: %w", err)
}
// The version record is what telemetry reports for the self-built
// core (its `version` output carries no stamp) — same convention
// as install-agent.sh.
_ = os.WriteFile(filepath.Join(binDir, singboxVersionRecord), []byte(singboxCoreVersion+"\n"), 0o644)
logger.Infof("sing-box core %s fetched from the release and self-checked; ready to switch", singboxCoreVersion)
return nil
}