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 }