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
- 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).
163 lines
5.9 KiB
Go
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
|
|
}
|