18 Commits
Author SHA1 Message Date
Muse a81eff8c0d install-agent: RELEASE_BASE -> V2.2.1 (sh + ps1)
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
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
2026-10-23 15:25:09 +00:00
Muse 1d7eb16784 merge feat/v221: windows probe degradation + agent heartbeat probe + update-check fallback + procd/OpenRC script fixes + probe traffic GB unification + public remark name (V2.2.1) 2026-10-23 15:24:55 +00:00
Muse a13179ed9b v221 ⑦ public views: node name prefers the public remark
buildProbeNodeView now sources its 'name' from probeDisplayName:
the node's public remark (probe_meta.public_remark) when non-empty
after trimming, else the node name as before. Contract field name
unchanged, theme packages untouched, both audiences (anonymous and
admin frames share the builder); the panel's internal node list is
a different surface and keeps the plain node name.

Tests: display-name unit cases (remark / empty / whitespace) and
view-level assertions for both audiences; the public contract
test's seeded expectation updated to the remark (its fixture sets
public_remark='shown publicly' on node 'PubNode').
2026-10-23 15:02:12 +00:00
Muse 93c1620c15 v221 ⑥ probe traffic: binary GB convention + one-shot calibration
Unit unification: GB entries converted at 1e9 while every byte
formatter is binary (1024^3), so a 3000 GB limit rendered 2.73 TB
instead of 2.93 TB. calBytes and the form's limit conversion now
use 1024^3. Stored traffic_limits (bytes, typed x1e9) are rescaled
x1.073741824 exactly once at store open, guarded by the
probe_setting mark gb_1024_migrated_v1; calibration values are
stored in GB (no rescale — only their conversion changed) and
price/sort/reset-day are unitless.

One-shot calibration: the form's cal fields are deltas to apply
now. probe.MergeCalDelta folds a non-zero submitted value into the
stored standing correction and keeps the standing value on a
zero/absent submission; putMeta merges against the stored row
(SaveMeta/UpsertMeta keep plain replace — the notify sweep relies
on it). GET no longer echoes the standing correction into the form,
and the frontend keeps the cal inputs as local state that starts
empty and clears on successful save, so a later unrelated save
resubmits nothing.

Tests: calBytes(3000)=3000x2^30; migration 3.0e12 -> 3000x2^30 with
no second rescale on reopen; one-shot flow (save delta, save
unrelated with zero cal -> calibrated traffic unmoved, second delta
accumulates once). Existing TestTrafficCalibrationAppliedAndFloored
expectation updated to the binary figure. No vitest covers
ProbeMetaSection (disclosed in the report).
2026-10-23 15:02:11 +00:00
Muse ba57fb3c00 v221 ⑤ install-agent: OpenRC respawn_period=0 rejected by supervise-daemon
The item-23 template wrote respawn_period=0 assuming it meant 'no
window' like respawn_max=0; supervise-daemon validates the value at
service start and refuses: Invalid respawn-period value '0'
(Alpine real-machine report). Template now writes 1800, the value
the machine's other supervise-daemon services use; with
respawn_max=0 (unlimited) the period is only the counting window.

Why existing Alpine nodes (Leg-US) never hit it: the respawn lines
exist only since 27a5097 (2026-10-22, item 23) — before that the
template used command_background with no respawn parameters at all,
and panel one-click agent updates swap only the binary, never
regenerate /etc/init.d/x-ui-agent. Only fresh installs (or script
reruns) on OpenRC with the post-item-23 template were exposed.
2026-10-23 14:27:23 +00:00
Muse 151b07b73f v221 ④ install-agent: OpenWrt/procd support + honest nohup fallback
detect_init_system gains a procd tier ([ -f /etc/rc.common ] &&
command -v procd, after systemd/OpenRC; XUI_INIT_SYSTEM=procd also
accepted). start_via_procd writes /etc/init.d/xui-agent byte-equal
to the unit hand-verified on AX6600 (paths via AGENT_BIN/CONF_DIR),
stops any previous instance before overwriting, enables + starts,
and verifies the process is alive (pgrep, ps fallback). The nohup
fallback now errors out immediately and clearly when nohup itself
is missing (BusyBox-trimmed systems) instead of pretending to start
and failing at the end.
2026-10-23 14:26:31 +00:00
Muse cec5905483 v221 ③ update check: fall back to raw version when display is unparseable
displayUpdateFields returned avail=false whenever the running
build's display version could not be compared (the VV2.1.0
double-letter builds), so the panel swallowed a real update prompt
and the native updater refused to run. displayUpdateFieldsFor now
takes the current display/raw versions as inputs and, when the
display comparison cannot judge, compares the latest tag against
the raw base version with the same tolerant display parser —
unparseable raw base yields false. (Not isNewerVersion: on this
fork's uppercase-V tags it degrades to string inequality, which
would phantom-report an update on form difference alone.)
Parseable display versions keep the segment rule untouched.
Regression tests cover the VV scenario in both raw forms.
2026-10-23 14:26:31 +00:00
Muse 1df78dd874 v221 ② node probe: agent nodes answer from heartbeat, not a dead dial
NodeService.Probe now checks for a bound agent first: agent-managed
nodes synthesize the result from agent_nodes.last_seen (MAX over
bound agents, ms) — fresh within the same 180s window the heartbeat
job expires by = online with the heartbeat age as latency; stale or
never-seen = offline with LatencyMs -1. No dial, and LastError is
never written for agent nodes (the stored dial error from the
placeholder address https://agent:2053 is also healed on next ⚡,
since the synthesized patch carries an empty error through
UpdateHeartbeat). Traditional-node dial path unchanged; verdict is a
pure function with unit tests.

Frontend: the ⚡ tooltip/menu used t('pages.nodes.probe'), which
since the probe-metadata work resolves to the status-page SECTION
object, so i18next fell back to rendering the raw key. The existing
pages.nodes.testConnection key (present in all 13 locales) is the
right label — point the three usages at it. No new i18n keys.
2026-10-23 14:26:31 +00:00
Muse 92dfdc542c v221 ① windows probe: gate optional procs via Find + per-source recover guard
LazyProc.Call must-finds and panics on a missing export; the trimmed
Tianyi cloud-PC image has no FreeMibIfTable2 in iphlpapi.dll, which
killed the agent on its first collection after a successful register.
Optional procs (GetSystemTimes/GlobalMemoryStatusEx/GetIfTable2Ex+
FreeMibIfTable2 as one gated pair) now resolve through sourceGate
(Find once, missing = source disabled with one warning); the iface
pair is gated together so a missing free proc never leaks the
system table. collectMetrics additionally runs every source through
guardedCall so any residual panic zeroes that source alone.
Pure gate/guard logic lives in probe_guard.go (no build tag) with
Linux-runnable regression tests.
2026-10-23 14:26:22 +00:00
Muse a25c51003e install-agent: RELEASE_BASE -> V2.2.0 (sh + ps1)
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
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
2026-10-23 13:30:56 +00:00
Muse b27bcfcb5b merge feat/v210-probeonly-win: windows probe agent + version display + reality sni fix (V2.2.0) 2026-10-23 13:30:40 +00:00
Muse 75f10b27fa windows probe-only agent + agent version display unification + reality sni from sing-box fragment
Scope B (Windows agent):
- probe.go split into shared core + probe_linux.go (verbatim Linux
  sampler, build-tagged) + probe_windows.go (Win32 sampler: GetSystemTimes
  CPU diff, GlobalMemoryStatusEx, fixed-drive df totals, GetIfTable2
  counted set, MachineGuid boot identity, registry facts; load and
  TCP/UDP counts stay zero - x/sys v0.47.0 exports no wrapper)
- update intents are acknowledged and skipped on Windows (the release
  assets are Linux binaries; Windows nodes update via install-agent.ps1)
- cmd/xui-agent-windows: SCM service host (svc.Run + mgr install/
  uninstall/start/stop) mirroring main.go's agent lifecycle wiring
- install-agent.ps1: downloads the exe, writes probeOnly agent.json,
  registers and starts the service, rerun-safe

Scope C (version display):
- config.GetAgentDisplayVersion (and the master getter) compose from
  the bare number so a V-form injection no longer doubles the letter
  (AV2.1.0 -> A2.1.0)
- node list view repairs stored AV-prefixed reports at assembly so
  deployed agents read uniformly without an update

Scope D (production fix):
- share-link reality sni: when the inbound has a sing-box fragment,
  sni is the fragment's tls.server_name - the only name the sing-box
  REALITY server serves. The previous rule picked a uniformly random
  serverNames entry per generation, which xray tolerated but sing-box
  refuses as an invalid connection. sid keeps its random pick: the
  converter carries the whole shortIds list into the fragment, so
  every listed sid is served.
2026-10-23 13:22:51 +00:00
Muse 7df3a338ef install-agent: RELEASE_BASE -> V2.1.0
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
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
2026-10-23 19:57:56 +08:00
Rogue 5ca22ec340 probe-only node mode: master flag + agent gate + panel switch (V2.1.0 A)
agent_nodes gains probe_only; getConfig always serves the flag and withholds the sing-box payload (null config, empty hash) for flagged nodes while probe tasks, fast interval, stat iface and update intents ride as usual. The agent gates both core branches when the local agent.json preset or the master flag is on, retires a running core once (best-effort tail flush, switchover bookkeeping untouched so turning the flag off self-heals through the existing hash-current-but-not-running path), and makes the traffic cycle an explicit no-op. Panel: node create/update accept probeOnly, the node view overlays it from the binding, the node form gains a switch (carried into the generated install command), the list shows a probe-only tag in the core-version cell, and install-agent.sh --probe-only writes the preset, skips the core download/self-check and inherits the flag on no-arg reruns.
2026-10-23 11:54:35 +00:00
Muse 2d37a6de18 install-agent: RELEASE_BASE -> V2.0.3
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
2026-10-23 18:45:22 +08:00
Muse db6f666ff7 probe: fast report stamps last-seen; snapshot TTL 900ms (V2.0.3)
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
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
2026-10-23 18:38:56 +08:00
Muse d4d259fa58 probe ws: frame cadence follows probe_fast_interval (V2.0.2)
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
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
2026-10-23 18:15:05 +08:00
Muse 9caa4e0cf9 install-agent: RELEASE_BASE -> V2.0.1
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
2026-10-23 16:13:25 +08:00
58 changed files with 3522 additions and 879 deletions
+270
View File
@@ -0,0 +1,270 @@
//go:build windows
// Command xui-agent-windows is the standalone Windows build of the
// 3x-ui node agent. Windows nodes are probe-only by design: the agent
// reports host facts/metrics and runs latency probes, but no xray or
// sing-box core is ever fetched or started here (the agent core
// machinery stays the master's Linux fleet concern; the local
// agent.json carries "probeOnly": true, written by
// install-agent.ps1).
//
// Usage:
//
// xui-agent-windows.exe [-config C:\x-ui\agent.json] run (service or console)
// xui-agent-windows.exe install [-config <path>] register the service
// xui-agent-windows.exe uninstall remove the service
// xui-agent-windows.exe start | stop control the service
//
// The agent lifecycle wiring (config load, baseline derivation,
// logger init, graceful stop) mirrors main.go's agent branch — the
// same internal/agent package does the work either way.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/agent"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/op/go-logging"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
const (
serviceName = "x-ui-agent"
defaultCfgPath = `C:\x-ui\agent.json`
)
func main() {
log.SetPrefix("[x-ui-agent] ")
args := os.Args[1:]
// Management subcommands come first; anything else is a run.
if len(args) > 0 {
switch args[0] {
case "install", "uninstall", "start", "stop":
cfgPath := parseConfigFlag("xui-agent-windows "+args[0], args[1:])
if err := runControl(args[0], cfgPath); err != nil {
log.Fatalf("%s: %v", args[0], err)
}
return
}
}
cfgPath := parseConfigFlag("xui-agent-windows", args)
isService, err := svc.IsWindowsService()
if err != nil {
log.Fatalf("detect service environment: %v", err)
}
if isService {
if err := svc.Run(serviceName, &agentHandler{cfgPath: cfgPath}); err != nil {
log.Fatalf("service run: %v", err)
}
return
}
// Interactive run: console foreground, Ctrl+C stops gracefully.
running, err := startAgent(cfgPath)
if err != nil {
log.Fatalf("start agent: %v", err)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
select {
case <-sigCh:
log.Println("Shutting down agent...")
running.Stop()
<-running.done
case err := <-running.done:
if err != nil {
log.Fatalf("Agent error: %v", err)
}
}
}
func parseConfigFlag(name string, args []string) string {
fs := flag.NewFlagSet(name, flag.ExitOnError)
cfg := fs.String("config", defaultCfgPath, "Path to agent config file")
_ = fs.Parse(args)
return *cfg
}
// runningAgent is one started agent plus its completion signal.
type runningAgent struct {
ag *agent.Agent
done chan error
}
func (r *runningAgent) Stop() { r.ag.Stop() }
// startAgent performs the same setup as main.go's agent branch and
// launches the agent loop in the background.
func startAgent(cfgPath string) (*runningAgent, error) {
// Agent mode uses the panel's directory layout for core binaries
// and configs; on a probe-only Windows node nothing consumes it,
// but keep the wiring identical to the Linux entry so behaviour
// cannot drift between the two.
if os.Getenv("XUI_BIN_FOLDER") == "" {
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
}
cfg, err := agent.LoadConfig(cfgPath)
if err != nil {
return nil, fmt.Errorf("load agent config from %s: %w", cfgPath, err)
}
// The acknowledged traffic baseline persists next to the agent
// config (its state home), unless the config names a path.
if cfg.BaselinePath == "" {
cfg.BaselinePath = filepath.Join(filepath.Dir(cfgPath), "agent-traffic-baseline.json")
}
logger.InitLogger(logging.INFO)
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
ag := agent.New(cfg)
r := &runningAgent{ag: ag, done: make(chan error, 1)}
go func() { r.done <- ag.Run() }()
return r, nil
}
// agentHandler adapts the agent loop to the SCM protocol.
type agentHandler struct {
cfgPath string
}
func (h *agentHandler) Execute(_ []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
const accepted = svc.AcceptStop | svc.AcceptShutdown
status <- svc.Status{State: svc.StartPending}
running, err := startAgent(h.cfgPath)
if err != nil {
log.Printf("start agent: %v", err)
return false, 1
}
status <- svc.Status{State: svc.Running, Accepts: accepted}
for {
select {
case err := <-running.done:
// The agent loop ended on its own (e.g. fatal run error).
if err != nil {
log.Printf("agent exited: %v", err)
return false, 1
}
return false, 0
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
status <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
status <- svc.Status{State: svc.StopPending}
running.Stop()
<-running.done
return false, 0
default:
log.Printf("unexpected service command: %v", c.Cmd)
}
}
}
}
// runControl implements the install/uninstall/start/stop subcommands
// through the service control manager.
func runControl(cmd, cfgPath string) error {
m, err := mgr.ConnectRemote("")
if err != nil {
return fmt.Errorf("connect to service manager: %w", err)
}
defer m.Disconnect()
switch cmd {
case "install":
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("locate own binary: %w", err)
}
absCfg, err := filepath.Abs(cfgPath)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
if s, err := m.OpenService(serviceName); err == nil {
s.Close()
return fmt.Errorf("service %s already exists (uninstall first)", serviceName)
}
s, err := m.CreateService(serviceName, exePath, mgr.Config{
DisplayName: "3x-ui Agent",
Description: "3x-ui probe-only node agent (facts, metrics and latency probes; runs no proxy core).",
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
}, "-config", absCfg)
if err != nil {
return fmt.Errorf("create service: %w", err)
}
defer s.Close()
log.Printf("service %s installed (config: %s)", serviceName, absCfg)
return nil
case "uninstall":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
// Stop first so the binary is not locked when the operator
// deletes or replaces it; an already-stopped service is fine.
_, _ = s.Control(svc.Stop)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
st, err := s.Query()
if err != nil || st.State == svc.Stopped {
break
}
time.Sleep(300 * time.Millisecond)
}
if err := s.Delete(); err != nil {
return fmt.Errorf("delete service: %w", err)
}
log.Printf("service %s removed", serviceName)
return nil
case "start":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
if err := s.Start(); err != nil {
return fmt.Errorf("start service: %w", err)
}
log.Printf("service %s started", serviceName)
return nil
case "stop":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
if _, err := s.Control(svc.Stop); err != nil {
return fmt.Errorf("stop service: %w", err)
}
log.Printf("service %s stop requested", serviceName)
return nil
}
return fmt.Errorf("unknown subcommand %q", cmd)
}
func openService(m *mgr.Mgr) (*mgr.Service, error) {
s, err := m.OpenService(serviceName)
if err != nil {
return nil, fmt.Errorf("open service %s: %w", serviceName, err)
}
return s, nil
}
+26 -4
View File
@@ -50,6 +50,7 @@ function defaultValues(): NodeFormValues {
basePath: '/',
apiToken: '',
hasStoredToken: false,
probeOnly: false,
enable: true,
allowPrivateAddress: false,
tlsVerifyMode: 'verify',
@@ -83,6 +84,9 @@ export default function NodeFormModal({
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const [agentToken, setAgentToken] = useState<string | null>(null);
const [agentNodeName, setAgentNodeName] = useState('');
// Whether the just-created agent node was flagged probe-only, so the
// install command below can carry the matching --probe-only flag.
const [agentProbeOnly, setAgentProbeOnly] = useState(false);
const [agentMasterUrl, setAgentMasterUrl] = useState('');
// Edit mode (item 19): the stored install material for an existing agent
// node, fetched from the master; null until loaded / for non-agent nodes.
@@ -100,6 +104,7 @@ export default function NodeFormModal({
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
const isAgent = nodeType === 'agent';
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -178,10 +183,13 @@ export default function NodeFormModal({
const base =
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
` --master ${editMasterUrl}`;
return shownInstall.hasToken
const cmd = shownInstall.hasToken
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
: `${base} --name ${shownInstall.name}`;
}, [shownInstall, editMasterUrl]);
// The toggle is the source of truth for the re-displayed command:
// flipping it rewrites the command the operator would re-run.
return probeOnly ? `${cmd} --probe-only` : cmd;
}, [shownInstall, editMasterUrl, probeOnly]);
const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -211,6 +219,9 @@ export default function NodeFormModal({
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
outboundTag: values.outboundTag || '',
};
// Probe-only (V2.1.0) is an agent-node concept; traditional
// payloads stay byte-identical to before.
if (agentMode) payload.probeOnly = values.probeOnly ?? false;
if (token) payload.apiToken = token;
return payload;
}
@@ -296,6 +307,7 @@ export default function NodeFormModal({
if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim());
setAgentProbeOnly(result.data.probeOnly ?? false);
// Default the master URL to the address this panel is being
// viewed through — origin AND base path. Dropping the base
// path made the agent's registration 404. User can edit it.
@@ -318,7 +330,7 @@ export default function NodeFormModal({
// one would produce a command that can never register, so withhold it
// (and say why) instead of letting the user copy a dead command.
const installCmd = agentMasterUrl
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}${agentProbeOnly ? ' --probe-only' : ''}`
: '';
return (
@@ -430,11 +442,21 @@ export default function NodeFormModal({
)}
{isAgent && (
<Row gutter={16}>
<Col xs={24}>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.probeOnly')}
name="probeOnly"
valueProp="checked"
tooltip={t('pages.nodes.probeOnlyHint')}
>
<Switch />
</FormField>
</Col>
</Row>
)}
+12 -4
View File
@@ -307,13 +307,13 @@ export default function NodeList({
</Tooltip>
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Tooltip title={t('pages.nodes.testConnection')}>
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<ThunderboltOutlined />}
aria-label={t('pages.nodes.probe')}
aria-label={t('pages.nodes.testConnection')}
onClick={() => onProbe(record)}
/>
</Tooltip>
@@ -495,12 +495,20 @@ export default function NodeList({
{
// Core version (V2.0.1 ②): agent rows run sing-box and show
// its version; traditional rows keep showing the xray one.
// Probe-only agent rows (V2.1.0) run no core by design: the
// cell says so instead of a bare '-', which reads as a fault.
title: t('pages.nodes.coreVersion'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) =>
record.nodeType === 'agent'
? record.singboxVersion || record.xrayVersion || '-'
? record.probeOnly
? (
<Tag color="cyan" style={{ margin: 0 }}>
{t('pages.nodes.probeOnly')}
</Tag>
)
: record.singboxVersion || record.xrayVersion || '-'
: record.xrayVersion || '-',
},
{
@@ -708,7 +716,7 @@ export default function NodeList({
key: 'probe',
label: (
<>
<ThunderboltOutlined /> {t('pages.nodes.probe')}
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
</>
),
onClick: () => onProbe(record),
+30 -8
View File
@@ -24,12 +24,20 @@ interface ProbeMetaWire {
ipv4Pin: string;
ipv6Pin: string;
// V2.0.1: ledger corrections in GB (either sign) and the counted
// interface pin ('' = automatic selection on the agent).
// interface pin ('' = automatic selection on the agent). Since
// V2.2.1 the corrections are one-shot deltas: the inputs below are
// local state (never prefilled from the wire), a save submits them
// once, and they clear back to empty on success.
trafficCalUpGb: number;
trafficCalDownGb: number;
statIface: string;
}
// GB entry converts on the binary convention every byte formatter in
// the panel and on the public page uses (V2.2.1: was decimal 1e9,
// which made a 3000 GB limit render as 2.73 TB instead of 2.93 TB).
const BYTES_PER_GB = 1024 ** 3;
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const NAMED_CYCLES = [
@@ -59,6 +67,9 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
// in NodeFormModal); visibility derives during render.
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
const [trafficGb, setTrafficGb] = useState(0);
// One-shot calibration deltas (GB, either sign): null = empty box.
const [calUpGb, setCalUpGb] = useState<number | null>(null);
const [calDownGb, setCalDownGb] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -68,7 +79,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
}).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setLoaded({ nodeId, meta: msg.obj });
setTrafficGb(msg.obj.trafficLimit / 1e9);
setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
}
});
return () => {
@@ -116,14 +127,23 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
try {
const payload: ProbeMetaWire = {
...meta,
trafficLimit: Math.round(trafficGb * 1e9),
trafficLimit: Math.round(trafficGb * BYTES_PER_GB),
trafficCalUpGb: calUpGb ?? 0,
trafficCalDownGb: calDownGb ?? 0,
};
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
setLoaded({ nodeId, meta: payload });
// One-shot: the submitted corrections are folded in server-
// side; the boxes clear so a later save resubmits nothing.
setLoaded({
nodeId,
meta: { ...payload, trafficCalUpGb: 0, trafficCalDownGb: 0 },
});
setCalUpGb(null);
setCalDownGb(null);
messageApi.success(t('pages.nodes.probe.saved'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
@@ -249,9 +269,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calUp')}>
<InputNumber
value={meta.trafficCalUpGb}
value={calUpGb}
step={0.1}
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
placeholder="0"
onChange={(v) => setCalUpGb(v)}
style={{ width: '100%' }}
/>
</Field>
@@ -259,9 +280,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calDown')}>
<InputNumber
value={meta.trafficCalDownGb}
value={calDownGb}
step={0.1}
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
placeholder="0"
onChange={(v) => setCalDownGb(v)}
style={{ width: '100%' }}
/>
</Field>
+5
View File
@@ -56,6 +56,9 @@ export const NodeRecordSchema = z
// P7: node kind, annotated by the backend for nodes registered by an
// agent. Absent/traditional for classic panel-to-panel nodes.
nodeType: z.enum(['traditional', 'agent']).optional(),
// V2.1.0: probe-only agent nodes run no core, only the probe
// channels. Annotated by the backend from the bound agent row.
probeOnly: z.boolean().optional(),
// P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
// Rendered as plain text in the address column for agent rows.
ipv4: z.string().optional(),
@@ -101,6 +104,8 @@ export const NodeFormSchema = z
// Agent nodes use an agent token instead of a panel API token.
apiToken: z.string().trim(),
hasStoredToken: z.boolean().optional().default(false),
// V2.1.0: probe-only toggle for agent nodes (see NodeRecordSchema).
probeOnly: z.boolean().optional().default(false),
enable: z.boolean(),
allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
+102
View File
@@ -0,0 +1,102 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Installs the 3x-ui probe-only agent on Windows (64-bit).
.DESCRIPTION
Downloads x-ui-agent-windows-amd64.exe into C:\x-ui, writes
agent.json with "probeOnly": true (Windows nodes run no proxy
core: facts, metrics and latency probes only), registers the
x-ui-agent Windows service (auto-start) and starts it.
Re-running is safe: an existing service is stopped and removed
first, and any of -Master/-Token/-Name left empty is inherited
from the agent.json already on disk.
.EXAMPLE
.\install-agent.ps1 -Master "https://panel.example.com/rogue" -Token "<agent token>" -Name "win-node-1"
#>
[CmdletBinding()]
param(
[string]$Master,
[string]$Token,
[string]$Name,
[string]$InstallDir = "C:\x-ui",
[string]$AgentUrl = $env:XUI_URL
)
$ErrorActionPreference = "Stop"
# Release download base, same shape as install-agent.sh's
# RELEASE_BASE (the version segment is bumped by the release flow).
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.1"
if (-not $AgentUrl) {
$AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe"
}
$ServiceName = "x-ui-agent"
$ExeName = "x-ui-agent-windows-amd64.exe"
$ExePath = Join-Path $InstallDir $ExeName
$ConfigPath = Join-Path $InstallDir "agent.json"
# Inherit missing parameters from an existing agent.json (reruns).
if (Test-Path $ConfigPath) {
$existing = Get-Content $ConfigPath -Raw | ConvertFrom-Json
if (-not $Master) { $Master = $existing.master_url }
if (-not $Token) { $Token = $existing.token }
if (-not $Name) { $Name = $existing.node_name }
}
if (-not $Master) { throw "missing -Master (and no existing agent.json to inherit from)" }
if (-not $Token) { throw "missing -Token (and no existing agent.json to inherit from)" }
if (-not $Name) { throw "missing -Name (and no existing agent.json to inherit from)" }
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
# Stop and remove any previous install so the exe file is not locked
# by a running service when it is replaced below. Prefer the old
# binary's own subcommands; fall back to sc.exe when it is gone.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc) {
if (Test-Path $ExePath) {
& $ExePath stop | Out-Null
& $ExePath uninstall | Out-Null
}
else {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
& sc.exe delete $ServiceName | Out-Null
}
Start-Sleep -Seconds 1
}
# Download the agent binary (via a temp file, then swap into place).
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$tmpExe = "$ExePath.new"
Write-Host "Downloading $AgentUrl"
Invoke-WebRequest -Uri $AgentUrl -OutFile $tmpExe -UseBasicParsing
if (-not (Test-Path $tmpExe) -or (Get-Item $tmpExe).Length -eq 0) {
throw "agent download produced an empty file: $AgentUrl"
}
Move-Item -Force $tmpExe $ExePath
# Write agent.json. Windows agents are probe-only: probeOnly is
# always set (the design contract, not an option).
$config = [ordered]@{
master_url = $Master
token = $Token
node_name = $Name
probeOnly = $true
}
$json = $config | ConvertTo-Json
# Go's encoding/json rejects a UTF-8 BOM, which Set-Content -Encoding
# UTF8 would add on Windows PowerShell 5.1: write BOM-less UTF-8.
[System.IO.File]::WriteAllText($ConfigPath, $json, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Wrote $ConfigPath (probeOnly)"
# Register and start the service.
& $ExePath install -config $ConfigPath
if ($LASTEXITCODE -ne 0) { throw "service install failed (exit $LASTEXITCODE)" }
& $ExePath start
if ($LASTEXITCODE -ne 0) { throw "service start failed (exit $LASTEXITCODE)" }
Write-Host "3x-ui agent installed and running as service '$ServiceName'."
Write-Host "Update later by re-running this script; the master's one-click agent update is intentionally not consumed on Windows."
+91 -14
View File
@@ -1,6 +1,8 @@
#!/bin/bash
# 3x-ui-agent 节点一键安装脚本
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] [--probe-only]
# --probe-only(V2.1.0):纯探针安装——跳过 sing-box 核心下载与自检,
# agent.json 写入 probeOnly=true;agent 只跑探针通道、不运行任何核心。
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
#
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
@@ -9,7 +11,7 @@
#
# 测试用环境变量(一般无需设置):
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | procd | none,默认 auto 探测
# XUI_URL agent 二进制下载地址覆盖
#
# 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用
@@ -28,12 +30,13 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行"
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
MASTER=""; TOKEN=""; NAME=""
MASTER=""; TOKEN=""; NAME=""; PROBE_ONLY=""
while [[ $# -gt 0 ]]; do
case $1 in
--master) MASTER="$2"; shift 2;;
--token) TOKEN="$2"; shift 2;;
--name) NAME="$2"; shift 2;;
--probe-only) PROBE_ONLY=1; shift;;
*) error "未知参数: $1";;
esac
done
@@ -53,6 +56,8 @@ if [[ -f "$AGENT_JSON_PATH" ]]; then
if [[ -z "$MASTER" && -n "$f_master" ]]; then MASTER="$f_master"; INHERITED+=("主控地址"); fi
if [[ -z "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); fi
if [[ -z "$NAME" && -n "$f_name" ]]; then NAME="$f_name"; INHERITED+=("节点名"); fi
# probeOnly 是布尔字段,json_get 只取字符串值,这里单独按 true 沿用。
if [[ -z "$PROBE_ONLY" ]] && grep -q '"probeOnly"[[:space:]]*:[[:space:]]*true' "$AGENT_JSON_PATH"; then PROBE_ONLY=1; INHERITED+=("纯探针"); fi
if [[ ${#INHERITED[@]} -gt 0 ]]; then
info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)"
elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then
@@ -76,7 +81,7 @@ esac
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
# 重装节点(节点升级走面板一键更新,不需要本脚本)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.0"
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.1"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}"
@@ -107,18 +112,30 @@ mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
SINGBOX_VER="1.13.21"
SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}"
SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}"
info "下载 sing-box v${SINGBOX_VER}..."
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
# 本机已有的核心文件不删(关掉纯探针后可直接复用)。
info "纯探针模式:跳过 sing-box 核心下载"
else
info "下载 sing-box v${SINGBOX_VER}..."
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
fi
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
# 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
# agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
# 写配置/起服务阶段。
self_check() {
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点没有核心可检(V2.1.0),只验 agent 本体。
[[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}"
info "纯探针模式:跳过 sing-box 自检"
return 0
fi
local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
local sb_out
@@ -137,11 +154,14 @@ self_check() {
self_check
info "写入 agent 配置..."
PROBE_ONLY_JSON=""
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
"probeOnly": true'; fi
cat > "${CONF_DIR}/agent.json" <<EOF
{
"master_url": "$MASTER",
"token": "$TOKEN",
"node_name": "$NAME"
"node_name": "$NAME"${PROBE_ONLY_JSON}
}
EOF
chmod 600 "${CONF_DIR}/agent.json"
@@ -200,7 +220,7 @@ stop_legacy_agent() {
detect_init_system() {
case "${XUI_INIT_SYSTEM:-auto}" in
systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;;
systemd|openrc|procd|none) echo "${XUI_INIT_SYSTEM}"; return;;
esac
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
echo systemd; return
@@ -208,6 +228,9 @@ detect_init_system() {
if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then
echo openrc; return
fi
if [ -f /etc/rc.common ] && command -v procd >/dev/null 2>&1; then
echo procd; return
fi
echo none
}
@@ -266,10 +289,14 @@ command_args="agent"
# supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起,
# 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background
# 只负责后台化、崩溃不重启。respawn_max=0 = 不限次(同 systemd 的持续重试)。
# respawn_period 必须为正数:supervise-daemon 启动时校验该值,0 直接报
# "Invalid respawn-period value '0'" 拒绝拉起(V2.2.1 ⑤,Alpine 真机实证);
# 取 1800(真机上其他 supervise-daemon 服务同值)。max=0 不限次时 period
# 仅是计数窗口,不影响持续重试语义。
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
respawn_period=0
respawn_period=1800
pidfile="/run/x-ui-agent.pid"
output_log="/var/log/x-ui-agent.log"
error_log="/var/log/x-ui-agent.log"
@@ -302,9 +329,58 @@ EOF
fi
}
# 以 procd 服务方式安装并启动(开机自启,OpenWrt)
start_via_procd() {
local init_dir="${ROOT}/etc/init.d"
mkdir -p "$init_dir"
# 服务脚本与 2026-10-23 AX6600 真机手工验证版逐字一致(V2.2.1 ④
# 收编):路径按脚本变量展开,XUI_ROOT 为空时即验证版的
# /usr/local/x-ui/xui-agent 与 /etc/x-ui/agent.json。procd 托管
# + respawn 守护,stdout/stderr 进 syslog(logread 查看)。
cat > "${init_dir}/xui-agent" <<EOF
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command ${AGENT_BIN} agent -config ${CONF_DIR}/agent.json
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
EOF
chmod +x "${init_dir}/xui-agent"
info "已生成 procd 服务文件: ${init_dir}/xui-agent"
if [ -n "$ROOT" ]; then
info "(XUI_ROOT 模式:跳过 /etc/init.d 实际操作)"
return 0
fi
/etc/init.d/xui-agent stop 2>/dev/null || true
stop_legacy_agent
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
/etc/init.d/xui-agent enable
/etc/init.d/xui-agent start
sleep 2
if pgrep -f "${AGENT_BIN} agent" >/dev/null 2>&1 || ps w 2>/dev/null | grep "[x]ui-agent agent" >/dev/null 2>&1; then
info "agent 已由 procd 托管启动(开机自启)"
echo ""
echo " 主控: $MASTER"
echo " 节点名: $NAME"
echo " 日志: logread -f"
echo " agent 会在 30 秒内从主控拉取配置"
else
error "agent 服务启动失败,查看: logread | tail -n 50"
fi
}
# 无服务管理器时回落 nohup(无开机自启,明确警告)
start_via_nohup() {
warn "未检测到 systemd / OpenRC,退回 nohup 方式启动"
# BusyBox 精简系统可能连 nohup 都没有(AX6600 实证):先讲清再退,
# 不要假装启动、走到最后才报一个看不懂的失败。
command -v nohup >/dev/null 2>&1 || error "未检测到 systemd / OpenRC / procd,且本机无 nohup 命令,无法启动 agent"
warn "未检测到 systemd / OpenRC / procd,退回 nohup 方式启动"
warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!"
stop_legacy_agent
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
@@ -328,5 +404,6 @@ info "启动 agent..."
case "$(detect_init_system)" in
systemd) start_via_systemd;;
openrc) start_via_openrc;;
procd) start_via_procd;;
*) start_via_nohup;;
esac
+67
View File
@@ -55,6 +55,11 @@ type Config struct {
// Empty: main derives it next to the agent config file; the agent
// falls back to the bin folder.
BaselinePath string `json:"baseline_path,omitempty"`
// ProbeOnly presets probe-only mode locally (V2.1.0): no core is
// fetched, started or stopped for the node's inbounds — only the
// probe channels run. The master's per-node flag (delivered on the
// config poll) enables the same mode; either source turns it on.
ProbeOnly bool `json:"probeOnly,omitempty"`
}
// Agent represents the node agent
@@ -124,6 +129,11 @@ type Agent struct {
fastIntervalSec atomic.Int64
fastUnsupported atomic.Bool
// Probe-only mode (V2.1.0): the master's per-node flag rides the
// config poll; the local config preset is the other source. While
// either is on, the poll skips both core branches entirely.
masterProbeOnly atomic.Bool
// Latency probe runner (item 7 B4, see ping.go): task set pushed
// on the config poll, samples returned on the heartbeat.
probes *probeRunner
@@ -505,6 +515,10 @@ func (a *Agent) fetchAndApplyConfig() error {
// report, automatic interface selection).
ProbeFastInterval *int64 `json:"probe_fast_interval"`
ProbeStatIface *string `json:"probe_stat_iface"`
// ProbeOnly is a pointer on purpose (V2.1.0), same as the other
// probe keys: a master too old to know the mode omits the field
// and the agent keeps its current state.
ProbeOnly *bool `json:"probe_only"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
@@ -523,6 +537,9 @@ func (a *Agent) fetchAndApplyConfig() error {
if result.ProbeStatIface != nil {
SetStatIfacePin(*result.ProbeStatIface)
}
if result.ProbeOnly != nil {
a.masterProbeOnly.Store(*result.ProbeOnly)
}
// One-click update intents ride this poll (item 20); handle them even
// when the config itself is unchanged.
@@ -534,6 +551,16 @@ func (a *Agent) fetchAndApplyConfig() error {
a.probes.applyTasks(*result.ProbeTasks)
}
// Probe-only mode (V2.1.0): no core config is applied at all. The
// master already withholds the payload for flagged nodes, but the
// local preset must gate even against a master that still serves
// one. A core left running from before the mode was turned on is
// retired by the convergence step.
if a.probeOnlyActive() {
a.retireCoresForProbeOnly()
return nil
}
// Switchover (ITEM6 B5): a master that serves a sing-box config
// has made sing-box the node's formal runtime. Apply the config
// through the switch sequence (which retires the legacy xray
@@ -598,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
return nil
}
// probeOnlyActive reports whether probe-only mode is in force from
// either source: the local agent.json preset or the master's
// per-node flag on the config poll.
func (a *Agent) probeOnlyActive() bool {
return a.config.ProbeOnly || a.masterProbeOnly.Load()
}
// retireCoresForProbeOnly converges a node that entered probe-only
// mode while a core was still running: the core's tail traffic is
// flushed best-effort (an explicit operator mode change outranks the
// unreported tail, unlike the switchover retire path, which keeps
// the core alive on a failed flush) and the core is stopped. Later
// polls find nothing running and skip, so the retirement happens
// exactly once per entry. The switchover bookkeeping (sbSwitched,
// lastSingboxHash) is left untouched on purpose: leaving probe-only
// re-serves the same config hash, and the existing "hash current but
// core not running" poll path reactivates the core on its own.
func (a *Agent) retireCoresForProbeOnly() {
if a.xrayProc != nil {
if err := a.collectAndReportXrayTraffic(); err != nil {
logger.Warningf("probe-only: flush xray traffic before retiring its core failed: %v", err)
}
a.stopXray()
logger.Info("probe-only mode: xray core retired")
}
if a.singboxRunning() {
if err := a.collectAndReportSingboxTraffic(); err != nil {
logger.Warningf("probe-only: flush sing-box traffic before retiring its core failed: %v", err)
}
a.stopSingbox()
logger.Info("probe-only mode: sing-box core retired")
}
}
// singboxRunning reports whether the managed sing-box process is
// currently alive.
func (a *Agent) singboxRunning() bool {
@@ -914,6 +975,12 @@ func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error
// (xray and, since ITEM6 B3, sing-box). The two cores keep separate
// clients and baselines; a failure in one never blocks the other.
func (a *Agent) collectAndReportTraffic() error {
// Probe-only nodes run no core (V2.1.0): the cycle is an explicit
// no-op, so a core retired by the mode can never be mistaken for
// a live one by a later tick.
if a.probeOnlyActive() {
return nil
}
var firstErr error
if err := a.collectAndReportXrayTraffic(); err != nil {
firstErr = err
+41 -786
View File
@@ -1,34 +1,34 @@
package agent
// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go:
// host Facts (once per process) and per-heartbeat Metrics read directly from
// /proc and /sys with the standard library only. The port keeps the source's
// probe.go is the platform-neutral core of the probe collector, ported
// from monitor-probe's agent collector (collect.rs, MIT): host Facts
// (once per process) and per-heartbeat Metrics with the source's
// accounting discipline:
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
// - network totals sum only "counted" interfaces — loopback, container,
// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer
// filter so no byte is counted twice;
// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set,
// so a set change re-baselines exactly like a reboot;
// - every source degrades independently: an unreadable file yields a zero
// field, never a failed collection round.
// - memory "used" is free(1)'s column: total - available;
// - disk "used" is df(1)'s: real storage only, total - free blocks
// (not the unprivileged-available figure);
// - network totals sum only "counted" interfaces — loopback,
// container, tunnel and stacked devices are excluded by a filter so
// no byte is counted twice;
// - boot epoch = a stable boot identity + FNV-1a fingerprint of the
// counted set, so a set change re-baselines exactly like a reboot;
// - every source degrades independently: an unreadable source yields
// a zero field, never a failed collection round.
//
// The samplers themselves are per-OS: probe_linux.go reads /proc and
// /sys, probe_windows.go reads the Win32 performance/registry surface.
// Both feed the shared rate/epoch machinery below and must keep the
// same accounting semantics; where a source has no equivalent on the
// platform, the field stays zero (documented at the sampler).
import (
"hash/fnv"
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled
@@ -72,7 +72,7 @@ type ProbeMetrics struct {
}
// probeCollector holds the cross-sample state the rates need (previous CPU
// jiffies and per-interface counters). Kept in memory only, like the source.
// counters and per-interface counters). Kept in memory only, like the source.
type probeCollector struct {
mu sync.Mutex
prevCPU *cpuSample
@@ -80,6 +80,9 @@ type probeCollector struct {
prevNetT time.Time
}
// cpuSample is one cumulative CPU reading in whatever tick unit the
// platform reports: total covers every state, idle only the idle state.
// The differencing in cpuPercent is unit-agnostic.
type cpuSample struct{ total, idle uint64 }
var (
@@ -94,37 +97,15 @@ func collectFacts() *ProbeFacts {
return factsCache
}
func sampleFacts() *ProbeFacts {
mem := parseMeminfo(readProcFile("meminfo"))
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
v4, v6 := probeAddresses()
return &ProbeFacts{
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
OS: osPrettyName(),
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
Arch: runtime.GOARCH,
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
CPUName: cpuName,
CPUCores: cpuCores,
MemTotal: mem["MemTotal"],
SwapTotal: mem["SwapTotal"],
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// statIfacePin (V2.0.1) names the single interface to count when
// the operator pinned one on the master (its probe_meta.stat_iface,
// delivered on the config poll). "" keeps the automatic counted-set
// selection below. The pin bypasses the three-layer filter on
// purpose: the operator named this exact device, including when it
// is one the filter would skip (a tunnel the node actually bills
// on). A pinned name absent from /proc/net/dev counts nothing —
// the boot epoch changes with the counted set, so the master
// re-baselines instead of booking a phantom delta.
// selection. The pin bypasses the filter on purpose: the operator
// named this exact device, including when it is one the filter would
// skip (a tunnel the node actually bills on). A pinned name absent
// from the interface table counts nothing — the boot epoch changes
// with the counted set, so the master re-baselines instead of booking
// a phantom delta.
var statIfacePin atomic.Value // string
// SetStatIfacePin records the master's counted-interface pin.
@@ -137,6 +118,12 @@ func statIfacePinValue() string {
return ""
}
// netCounters is one interface's cumulative byte totals.
type netCounters struct {
name string
rx, tx uint64
}
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
for _, c := range all {
if c.name == pin {
@@ -146,151 +133,9 @@ func pinnedInterfaces(all []netCounters, pin string) []netCounters {
return nil
}
// collectMetrics takes one full sample. Individual sources fail soft.
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
mem := parseMeminfo(readProcFile("meminfo"))
memTotal, memUsed := memUsedFree(mem)
swapTotal, swapUsed := swapUsedFree(mem)
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
allIfaces := parseNetDev(readProcFile("net/dev"))
counted := countedInterfaces(allIfaces, sysNetRoot)
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(allIfaces, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
rxRate, txRate := probeColl.netRate(counted, time.Now())
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
return &ProbeMetrics{
BootID: bootID,
Uptime: parseUptime(readProcFile("uptime")),
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
Load: parseLoadavg(readProcFile("loadavg")),
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: tcp,
UDP: udp,
Procs: procCount(),
}
}
// --- file helpers -----------------------------------------------------------
const sysNetRoot = "/sys/class/net"
func readProcFile(name string) string {
b, err := os.ReadFile(filepath.Join("/proc", name))
if err != nil {
return ""
}
return string(b)
}
func readTrimFile(path, fallback string) string {
b, err := os.ReadFile(path)
if err != nil {
return fallback
}
if s := strings.TrimSpace(string(b)); s != "" {
return s
}
return fallback
}
// --- memory (free口径) -------------------------------------------------------
func parseMeminfo(text string) map[string]uint64 {
out := make(map[string]uint64)
for _, line := range strings.Split(text, "\n") {
key, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) == 0 {
continue
}
kb, err := strconv.ParseUint(f[0], 10, 64)
if err != nil {
continue
}
out[key] = kb * 1024
}
return out
}
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
// available above the total, as LXCFS can report, -> total - MemFree).
func memUsedFree(m map[string]uint64) (total, used uint64) {
total = m["MemTotal"]
if total == 0 {
return 0, 0
}
avail, ok := m["MemAvailable"]
if !ok {
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
}
if avail > total {
avail = m["MemFree"]
}
if avail > total {
avail = total
}
return total, total - avail
}
func swapUsedFree(m map[string]uint64) (total, used uint64) {
total = m["SwapTotal"]
free := m["SwapFree"]
if free > total {
free = total
}
return total, total - free
}
// --- cpu --------------------------------------------------------------------
func parseCPUJiffies(stat string) *cpuSample {
for _, line := range strings.Split(stat, "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line[4:])
if len(f) < 5 {
return nil
}
var v [8]uint64
var total uint64
for i := 0; i < len(f) && i < 8; i++ {
n, err := strconv.ParseUint(f[i], 10, 64)
if err != nil {
return nil
}
v[i] = n
total += n
}
return &cpuSample{total: total, idle: v[3] + v[4]}
}
return nil
}
// cpuPercent diffs two cumulative samples into a busy percentage. A
// missing or non-advancing previous sample reports 0 (the round only
// re-baselines) — the source's discipline on both platforms.
func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
if now == nil {
return 0
@@ -315,344 +160,14 @@ func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
return pct
}
func parseLoadavg(text string) [3]float32 {
var out [3]float32
f := strings.Fields(text)
for i := 0; i < 3 && i < len(f); i++ {
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
out[i] = float32(v)
}
}
return out
}
func parseUptime(text string) uint64 {
f := strings.Fields(text)
if len(f) == 0 {
return 0
}
v, err := strconv.ParseFloat(f[0], 64)
if err != nil || v < 0 {
return 0
}
return uint64(v)
}
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
func cpuInfo(text string) (string, uint32) {
name := ""
cores := uint32(0)
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "processor":
cores++
case "model name", "Model", "cpu model":
if name == "" {
name = strings.TrimSpace(val)
}
}
}
if cores == 0 {
cores = uint32(runtime.NumCPU())
if cores == 0 {
cores = 1
}
}
if name == "" {
name = armCPUName(text)
}
if name == "" {
name = "unknown"
}
return name, cores
}
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
func armCPUName(text string) string {
var vendor *armVendor
var names []string
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "CPU implementer":
vendor = nil
if id, ok := parseHexID(val); ok {
for i := range armVendors {
if armVendors[i].id == id {
vendor = &armVendors[i]
}
}
}
case "CPU part":
if vendor == nil {
continue
}
name := vendor.name
if id, ok := parseHexID(val); ok {
for _, p := range vendor.parts {
if p.id == id {
name = p.name
}
}
}
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
return strings.Join(names, "/")
}
func parseHexID(s string) (uint32, bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return uint32(v), true
}
type armPart struct {
id uint32
name string
}
type armVendor struct {
id uint32
name string
parts []armPart
}
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
var armVendors = []armVendor{
{0x41, "ARM", []armPart{
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
}},
{0x48, "HiSilicon", []armPart{
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
}},
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
}
// --- os / virt ---------------------------------------------------------------
func osPrettyName() string {
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
b, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(v, "\"'")
}
}
}
return "Linux"
}
// detectVirt ports the source's chain: containers first, then hypervisor.
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
// reads as "none", same caveat the source documents for cleared CPUID bits.
func detectVirt(mounts, cpuinfo string) string {
if _, err := os.Stat("/proc/vz"); err == nil {
if _, err := os.Stat("/proc/bc"); err != nil {
return "openvz"
}
}
if s := readTrimFile("/run/systemd/container", ""); s != "" {
return s
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return "docker"
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
return "podman"
}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
return "lxc"
}
}
if _, err := os.Stat("/proc/xen"); err == nil {
return "xen"
}
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
return "xen"
}
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
for _, m := range []struct{ word, name string }{
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
{"bhyve", "bhyve"}, {"xen", "xen"},
} {
if strings.Contains(dmi, m.word) {
return m.name
}
}
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
return "kvm"
}
if strings.Contains(cpuinfo, "hypervisor") {
return "vm"
}
return "none"
}
// --- network: counted set, epoch, rates -------------------------------------
type netCounters struct {
name string
rx, tx uint64
}
func parseNetDev(text string) []netCounters {
var out []netCounters
lines := strings.Split(text, "\n")
for _, line := range lines {
name, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) < 16 {
continue
}
rx, err1 := strconv.ParseUint(f[0], 10, 64)
tx, err2 := strconv.ParseUint(f[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
}
return out
}
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
var skipIfacePrefixes = []string{
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
}
func skipIfaceName(name string) bool {
for _, p := range skipIfacePrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
// the kernel books their bytes again on the device beneath.
func isStackedName(name string) bool {
if strings.Contains(name, ".") {
return true
}
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
var tunnelLinkTypes = map[string]bool{
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
}
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
// are already counted on another device. Hardware (a `device` link) exempts
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
// type with WireGuard but carries real traffic.
func countedElsewhere(sysRoot, name string) bool {
dev := filepath.Join(sysRoot, name)
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
devtypeIs := func(set ...string) bool {
for _, line := range strings.Split(uevent, "\n") {
for _, want := range set {
if line == want {
return true
}
}
}
return false
}
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
return true
}
if entries, err := os.ReadDir(dev); err == nil {
for _, e := range entries {
if strings.HasPrefix(e.Name(), "lower_") {
return true
}
}
}
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
return false
}
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
return true
}
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
return true
}
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
}
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
func ifaceCounts(sysRoot, name string) bool {
if skipIfaceName(name) || isStackedName(name) {
return false
}
return !countedElsewhere(sysRoot, name)
}
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
out := make([]netCounters, 0, len(all))
for _, c := range all {
if ifaceCounts(sysRoot, c.name) {
out = append(out, c)
}
}
return out
}
// bootEpoch = "{kernel boot_id}/{FNV-1a-64 of the sorted counted names}".
// bootEpoch = "{boot identity}/{FNV-1a-64 of the sorted counted names}".
// The master only tests it for equality: any change re-baselines.
func bootEpoch(kernelBootID string, names []string) string {
func bootEpoch(bootID string, names []string) string {
sorted := append([]string(nil), names...)
sort.Strings(sorted)
h := fnv.New64a()
_, _ = h.Write([]byte(strings.Join(sorted, "\n")))
return kernelBootID + "/" + hex16(h.Sum64())
return bootID + "/" + hex16(h.Sum64())
}
func hex16(v uint64) string {
@@ -697,136 +212,6 @@ func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64,
return uint64(float64(rx) / secs), uint64(float64(tx) / secs)
}
// --- addresses (scope §1.5) ---------------------------------------------------
// probeAddresses picks one address per family: candidates come from
// non-skip interfaces that are up, are not link-local, prefer public over
// private, and for v6 avoid temporary/deprecated/tentative addresses.
func probeAddresses() (string, string) {
transient := transientV6(readProcFile("net/if_inet6"))
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
type cand struct {
ip net.IP
transient bool
}
var held []cand
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 {
continue
}
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
if !lendsAddress(ifc.Name, ip) {
continue
}
c := cand{ip: ip}
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
for _, t := range transient {
if t.Equal(ip6) {
c.transient = true
}
}
}
held = append(held, c)
}
}
pick := func(v6 bool) string {
var best *cand
for i := range held {
c := &held[i]
if (c.ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = c
continue
}
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
if cp != bp {
if cp {
best = c
}
continue
}
if v6 && best.transient && !c.transient {
best = c
}
}
if best == nil {
return ""
}
return best.ip.String()
}
return pick(false), pick(true)
}
// lendsAddress mirrors the source: a skipped interface lends no address,
// except a WireGuard interface lending a public v6 that is not in
// Cloudflare WARP's shared 2606:4700::/32.
func lendsAddress(name string, ip net.IP) bool {
if !skipIfaceName(name) {
return true
}
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
ip16 := ip.To16()
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
}
return false
}
// transientV6 parses /proc/net/if_inet6 for addresses carrying
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
func transientV6(text string) []net.IP {
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
var out []net.IP
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 6 {
continue
}
flags, err := strconv.ParseUint(f[4], 16, 32)
if err != nil || flags&transientFlags == 0 {
continue
}
if len(f[0]) != 32 {
continue
}
ip := make(net.IP, 16)
ok := true
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
if err != nil {
ok = false
break
}
ip[i] = byte(b)
}
if ok {
out = append(out, ip)
}
}
return out
}
// isPublicIP mirrors monitor's is_public: v4 excludes RFC1918, CGNAT
// 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
// v6 is only 2000::/3.
@@ -853,133 +238,3 @@ func isPublicIP(ip net.IP) bool {
}
return ip16[0]&0xe0 == 0x20
}
// --- sockets / processes ------------------------------------------------------
func connCounts(v4, v6 string) (tcp, udp uint32) {
stat := func(text, prefix, key string) uint32 {
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, prefix) {
continue
}
f := strings.Fields(line[len(prefix):])
for i := 0; i+1 < len(f); i++ {
if f[i] == key {
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
return uint32(n)
}
return 0
}
}
}
return 0
}
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
return tcp, udp
}
func procCount() uint32 {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
var n uint32
for _, e := range entries {
name := e.Name()
digits := name != ""
for i := 0; i < len(name); i++ {
if name[i] < '0' || name[i] > '9' {
digits = false
break
}
}
if digits {
n++
}
}
return n
}
// --- disk (df口径) ---------------------------------------------------------------
var skipFSTypes = map[string]bool{
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
"fuse": true, "rpc_pipefs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
"glusterfs": true, "9p": true,
}
func skipFSType(fstype string) bool {
if skipFSTypes[fstype] {
return true
}
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
return true
}
return false
}
// realMountPoints ports parse_mounts: only the topmost mount per path,
// deduped by source device (zfs by pool), real storage only.
func realMountPoints(text string) []string {
type row struct{ dev, mount, fstype string }
var rows []row
top := map[string]int{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
rows = append(rows, row{f[0], f[1], f[2]})
top[f[1]] = len(rows) - 1
}
seen := map[string]bool{}
var out []string
for i, r := range rows {
if top[r.mount] != i || skipFSType(r.fstype) {
continue
}
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
continue
}
key := r.dev
if r.fstype == "zfs" {
if j := strings.IndexByte(key, '/'); j >= 0 {
key = key[:j]
}
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
}
return out
}
// diskUsage sums statfs over the mounts: total = f_blocks, used =
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
func diskUsage(mounts []string) (total, used uint64) {
for _, m := range mounts {
var st syscall.Statfs_t
if err := syscall.Statfs(m, &st); err != nil {
continue
}
bs := uint64(st.Frsize)
if bs == 0 {
bs = uint64(st.Bsize)
}
total += st.Blocks * bs
if st.Blocks > st.Bfree {
used += (st.Blocks - st.Bfree) * bs
}
}
return total, used
}
+56
View File
@@ -0,0 +1,56 @@
package agent
import (
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// This file holds the probe collector's degrade-don't-die plumbing:
// the per-source independence discipline as reusable mechanics,
// shared by every platform sampler but exercised today by the Windows
// sampler (the Linux sampler reads /proc files, whose failure mode is
// an error return, not a panic).
//
// Two layers:
//
// - source gates: an optional OS facility (a Win32 procedure, say)
// is probed once; when it is missing the source is skipped —
// zeroed — with a single warning, never re-probed, and never
// reached through a call path that panics on a missing export the
// way LazyProc.Call does when it must-finds a procedure;
// - guarded calls: a panicking source is recovered, zeroed and
// logged, so one bad source can never take the agent process (and
// every other source with it) down.
// sourceGate caches one availability probe. The zero value is ready.
type sourceGate struct {
once sync.Once
available bool
}
// enabled runs probe on first use and reports whether the source may
// be used. probe returns nil when the facility is present. A failed
// probe disables the source for the process lifetime and logs once.
func (g *sourceGate) enabled(name string, probe func() error) bool {
g.once.Do(func() {
if err := probe(); err != nil {
logger.Warningf("probe: %s unavailable, source disabled: %v", name, err)
return
}
g.available = true
})
return g.available
}
// guardedCall runs fn and converts a panic into the zero value plus a
// warning, so one failing metrics source degrades alone.
func guardedCall[T any](name string, fn func() T) (out T) {
defer func() {
if r := recover(); r != nil {
logger.Warningf("probe: %s panicked, source zeroed: %v", name, r)
out = *new(T)
}
}()
return fn()
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"errors"
"testing"
)
// The degrade-don't-die mechanics behind V2.2.1 ①: on the trimmed
// Tianyi cloud-PC image the Windows sampler died on its first
// collection because a missing iphlpapi export made LazyProc.Call
// panic. These tests pin the two layers that replace that failure
// mode — a failed availability probe disables the source (probed
// once, not per poll), and a panicking source is zeroed, never
// propagated.
func TestSourceGateDisablesFailedProbeOnce(t *testing.T) {
var g sourceGate
calls := 0
probe := func() error {
calls++
return errors.New("procedure not found")
}
for i := range 3 {
if g.enabled("test-source", probe) {
t.Fatalf("call %d: gate enabled despite failed probe", i)
}
}
if calls != 1 {
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
}
}
func TestSourceGateEnablesSuccessfulProbeOnce(t *testing.T) {
var g sourceGate
calls := 0
probe := func() error {
calls++
return nil
}
for i := range 3 {
if !g.enabled("test-source", probe) {
t.Fatalf("call %d: gate disabled despite successful probe", i)
}
}
if calls != 1 {
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
}
}
func TestGuardedCallRecoversPanicToZero(t *testing.T) {
got := guardedCall("test-source", func() uint64 {
panic("Failed to find FreeMibIfTable2 procedure in iphlpapi.dll")
})
if got != 0 {
t.Fatalf("guardedCall after panic = %d, want zero value", got)
}
}
func TestGuardedCallPassesValueThrough(t *testing.T) {
if got := guardedCall("test-source", func() string { return "ok" }); got != "ok" {
t.Fatalf("guardedCall = %q, want %q", got, "ok")
}
}
+778
View File
@@ -0,0 +1,778 @@
//go:build linux
package agent
// probe_linux.go is the Linux sampler for the probe collector: host
// Facts and per-heartbeat Metrics read directly from /proc and /sys
// with the standard library only. The accounting discipline (see
// probe.go) is the source's:
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
// - network totals sum only "counted" interfaces, selected by the
// three-layer filter below so no byte is counted twice;
// - boot epoch = kernel boot_id + fingerprint of the counted set.
import (
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
func sampleFacts() *ProbeFacts {
mem := parseMeminfo(readProcFile("meminfo"))
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
v4, v6 := probeAddresses()
return &ProbeFacts{
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
OS: osPrettyName(),
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
Arch: runtime.GOARCH,
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
CPUName: cpuName,
CPUCores: cpuCores,
MemTotal: mem["MemTotal"],
SwapTotal: mem["SwapTotal"],
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// collectMetrics takes one full sample. Individual sources fail soft.
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
mem := parseMeminfo(readProcFile("meminfo"))
memTotal, memUsed := memUsedFree(mem)
swapTotal, swapUsed := swapUsedFree(mem)
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
allIfaces := parseNetDev(readProcFile("net/dev"))
counted := countedInterfaces(allIfaces, sysNetRoot)
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(allIfaces, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
rxRate, txRate := probeColl.netRate(counted, time.Now())
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
return &ProbeMetrics{
BootID: bootID,
Uptime: parseUptime(readProcFile("uptime")),
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
Load: parseLoadavg(readProcFile("loadavg")),
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: tcp,
UDP: udp,
Procs: procCount(),
}
}
// --- file helpers -----------------------------------------------------------
const sysNetRoot = "/sys/class/net"
func readProcFile(name string) string {
b, err := os.ReadFile(filepath.Join("/proc", name))
if err != nil {
return ""
}
return string(b)
}
func readTrimFile(path, fallback string) string {
b, err := os.ReadFile(path)
if err != nil {
return fallback
}
if s := strings.TrimSpace(string(b)); s != "" {
return s
}
return fallback
}
// --- memory (free口径) -------------------------------------------------------
func parseMeminfo(text string) map[string]uint64 {
out := make(map[string]uint64)
for _, line := range strings.Split(text, "\n") {
key, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) == 0 {
continue
}
kb, err := strconv.ParseUint(f[0], 10, 64)
if err != nil {
continue
}
out[key] = kb * 1024
}
return out
}
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
// available above the total, as LXCFS can report, -> total - MemFree).
func memUsedFree(m map[string]uint64) (total, used uint64) {
total = m["MemTotal"]
if total == 0 {
return 0, 0
}
avail, ok := m["MemAvailable"]
if !ok {
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
}
if avail > total {
avail = m["MemFree"]
}
if avail > total {
avail = total
}
return total, total - avail
}
func swapUsedFree(m map[string]uint64) (total, used uint64) {
total = m["SwapTotal"]
free := m["SwapFree"]
if free > total {
free = total
}
return total, total - free
}
// --- cpu --------------------------------------------------------------------
func parseCPUJiffies(stat string) *cpuSample {
for _, line := range strings.Split(stat, "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line[4:])
if len(f) < 5 {
return nil
}
var v [8]uint64
var total uint64
for i := 0; i < len(f) && i < 8; i++ {
n, err := strconv.ParseUint(f[i], 10, 64)
if err != nil {
return nil
}
v[i] = n
total += n
}
return &cpuSample{total: total, idle: v[3] + v[4]}
}
return nil
}
func parseLoadavg(text string) [3]float32 {
var out [3]float32
f := strings.Fields(text)
for i := 0; i < 3 && i < len(f); i++ {
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
out[i] = float32(v)
}
}
return out
}
func parseUptime(text string) uint64 {
f := strings.Fields(text)
if len(f) == 0 {
return 0
}
v, err := strconv.ParseFloat(f[0], 64)
if err != nil || v < 0 {
return 0
}
return uint64(v)
}
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
func cpuInfo(text string) (string, uint32) {
name := ""
cores := uint32(0)
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "processor":
cores++
case "model name", "Model", "cpu model":
if name == "" {
name = strings.TrimSpace(val)
}
}
}
if cores == 0 {
cores = uint32(runtime.NumCPU())
if cores == 0 {
cores = 1
}
}
if name == "" {
name = armCPUName(text)
}
if name == "" {
name = "unknown"
}
return name, cores
}
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
func armCPUName(text string) string {
var vendor *armVendor
var names []string
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "CPU implementer":
vendor = nil
if id, ok := parseHexID(val); ok {
for i := range armVendors {
if armVendors[i].id == id {
vendor = &armVendors[i]
}
}
}
case "CPU part":
if vendor == nil {
continue
}
name := vendor.name
if id, ok := parseHexID(val); ok {
for _, p := range vendor.parts {
if p.id == id {
name = p.name
}
}
}
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
return strings.Join(names, "/")
}
func parseHexID(s string) (uint32, bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return uint32(v), true
}
type armPart struct {
id uint32
name string
}
type armVendor struct {
id uint32
name string
parts []armPart
}
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
var armVendors = []armVendor{
{0x41, "ARM", []armPart{
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
}},
{0x48, "HiSilicon", []armPart{
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
}},
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
}
// --- os / virt ---------------------------------------------------------------
func osPrettyName() string {
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
b, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(v, "\"'")
}
}
}
return "Linux"
}
// detectVirt ports the source's chain: containers first, then hypervisor.
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
// reads as "none", same caveat the source documents for cleared CPUID bits.
func detectVirt(mounts, cpuinfo string) string {
if _, err := os.Stat("/proc/vz"); err == nil {
if _, err := os.Stat("/proc/bc"); err != nil {
return "openvz"
}
}
if s := readTrimFile("/run/systemd/container", ""); s != "" {
return s
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return "docker"
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
return "podman"
}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
return "lxc"
}
}
if _, err := os.Stat("/proc/xen"); err == nil {
return "xen"
}
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
return "xen"
}
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
for _, m := range []struct{ word, name string }{
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
{"bhyve", "bhyve"}, {"xen", "xen"},
} {
if strings.Contains(dmi, m.word) {
return m.name
}
}
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
return "kvm"
}
if strings.Contains(cpuinfo, "hypervisor") {
return "vm"
}
return "none"
}
// --- network: counted set, epoch, rates -------------------------------------
func parseNetDev(text string) []netCounters {
var out []netCounters
lines := strings.Split(text, "\n")
for _, line := range lines {
name, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) < 16 {
continue
}
rx, err1 := strconv.ParseUint(f[0], 10, 64)
tx, err2 := strconv.ParseUint(f[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
}
return out
}
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
var skipIfacePrefixes = []string{
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
}
func skipIfaceName(name string) bool {
for _, p := range skipIfacePrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
// the kernel books their bytes again on the device beneath.
func isStackedName(name string) bool {
if strings.Contains(name, ".") {
return true
}
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
var tunnelLinkTypes = map[string]bool{
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
}
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
// are already counted on another device. Hardware (a `device` link) exempts
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
// type with WireGuard but carries real traffic.
func countedElsewhere(sysRoot, name string) bool {
dev := filepath.Join(sysRoot, name)
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
devtypeIs := func(set ...string) bool {
for _, line := range strings.Split(uevent, "\n") {
for _, want := range set {
if line == want {
return true
}
}
}
return false
}
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
return true
}
if entries, err := os.ReadDir(dev); err == nil {
for _, e := range entries {
if strings.HasPrefix(e.Name(), "lower_") {
return true
}
}
}
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
return false
}
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
return true
}
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
return true
}
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
}
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
func ifaceCounts(sysRoot, name string) bool {
if skipIfaceName(name) || isStackedName(name) {
return false
}
return !countedElsewhere(sysRoot, name)
}
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
out := make([]netCounters, 0, len(all))
for _, c := range all {
if ifaceCounts(sysRoot, c.name) {
out = append(out, c)
}
}
return out
}
// --- addresses (scope §1.5) ---------------------------------------------------
// probeAddresses picks one address per family: candidates come from
// non-skip interfaces that are up, are not link-local, prefer public over
// private, and for v6 avoid temporary/deprecated/tentative addresses.
func probeAddresses() (string, string) {
transient := transientV6(readProcFile("net/if_inet6"))
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
type cand struct {
ip net.IP
transient bool
}
var held []cand
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 {
continue
}
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
if !lendsAddress(ifc.Name, ip) {
continue
}
c := cand{ip: ip}
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
for _, t := range transient {
if t.Equal(ip6) {
c.transient = true
}
}
}
held = append(held, c)
}
}
pick := func(v6 bool) string {
var best *cand
for i := range held {
c := &held[i]
if (c.ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = c
continue
}
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
if cp != bp {
if cp {
best = c
}
continue
}
if v6 && best.transient && !c.transient {
best = c
}
}
if best == nil {
return ""
}
return best.ip.String()
}
return pick(false), pick(true)
}
// lendsAddress mirrors the source: a skipped interface lends no address,
// except a WireGuard interface lending a public v6 that is not in
// Cloudflare WARP's shared 2606:4700::/32.
func lendsAddress(name string, ip net.IP) bool {
if !skipIfaceName(name) {
return true
}
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
ip16 := ip.To16()
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
}
return false
}
// transientV6 parses /proc/net/if_inet6 for addresses carrying
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
func transientV6(text string) []net.IP {
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
var out []net.IP
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 6 {
continue
}
flags, err := strconv.ParseUint(f[4], 16, 32)
if err != nil || flags&transientFlags == 0 {
continue
}
if len(f[0]) != 32 {
continue
}
ip := make(net.IP, 16)
ok := true
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
if err != nil {
ok = false
break
}
ip[i] = byte(b)
}
if ok {
out = append(out, ip)
}
}
return out
}
// --- sockets / processes ------------------------------------------------------
func connCounts(v4, v6 string) (tcp, udp uint32) {
stat := func(text, prefix, key string) uint32 {
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, prefix) {
continue
}
f := strings.Fields(line[len(prefix):])
for i := 0; i+1 < len(f); i++ {
if f[i] == key {
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
return uint32(n)
}
return 0
}
}
}
return 0
}
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
return tcp, udp
}
func procCount() uint32 {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
var n uint32
for _, e := range entries {
name := e.Name()
digits := name != ""
for i := 0; i < len(name); i++ {
if name[i] < '0' || name[i] > '9' {
digits = false
break
}
}
if digits {
n++
}
}
return n
}
// --- disk (df口径) ---------------------------------------------------------------
var skipFSTypes = map[string]bool{
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
"fuse": true, "rpc_pipefs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
"glusterfs": true, "9p": true,
}
func skipFSType(fstype string) bool {
if skipFSTypes[fstype] {
return true
}
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
return true
}
return false
}
// realMountPoints ports parse_mounts: only the topmost mount per path,
// deduped by source device (zfs by pool), real storage only.
func realMountPoints(text string) []string {
type row struct{ dev, mount, fstype string }
var rows []row
top := map[string]int{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
rows = append(rows, row{f[0], f[1], f[2]})
top[f[1]] = len(rows) - 1
}
seen := map[string]bool{}
var out []string
for i, r := range rows {
if top[r.mount] != i || skipFSType(r.fstype) {
continue
}
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
continue
}
key := r.dev
if r.fstype == "zfs" {
if j := strings.IndexByte(key, '/'); j >= 0 {
key = key[:j]
}
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
}
return out
}
// diskUsage sums statfs over the mounts: total = f_blocks, used =
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
func diskUsage(mounts []string) (total, used uint64) {
for _, m := range mounts {
var st syscall.Statfs_t
if err := syscall.Statfs(m, &st); err != nil {
continue
}
bs := uint64(st.Frsize)
if bs == 0 {
bs = uint64(st.Bsize)
}
total += st.Blocks * bs
if st.Blocks > st.Bfree {
used += (st.Blocks - st.Bfree) * bs
}
}
return total, used
}
+209
View File
@@ -0,0 +1,209 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
)
// V2.1.0 probe-only tests: the mode gates both core branches of the
// config poll (no fetch, no start, no switch latch), retires a core
// that was running when the mode turns on, and self-heals back to
// the core when the mode turns off again.
// probeMaster is a fake master whose config response is assembled
// from a switchable probe_only flag plus the usual sing-box payload.
type probeMaster struct {
probeOnly atomic.Bool
trafficHit atomic.Int32
srv *httptest.Server
}
func newProbeMaster(t *testing.T) *probeMaster {
t.Helper()
m := &probeMaster{}
sum := sha256.Sum256([]byte(sbCfgA))
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"singbox_config": json.RawMessage(sbCfgA),
"singbox_config_hash": hex.EncodeToString(sum[:]),
"probe_tasks": []any{},
// The real master always sends the flag (only a pre-V2.1.0
// master omits it); the fixture mirrors that.
"probe_only": m.probeOnly.Load(),
}
_ = json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("/panel/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
m.trafficHit.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
m.srv = httptest.NewServer(mux)
t.Cleanup(m.srv.Close)
return m
}
func (m *probeMaster) agent(cfg *Config) *Agent {
if cfg == nil {
cfg = &Config{}
}
cfg.MasterURL = m.srv.URL
cfg.Token = "tok"
cfg.NodeName = "n"
return New(cfg)
}
// The master's flag alone (no local preset) must gate the poll: the
// served sing-box payload is ignored entirely.
func TestProbeOnlyMasterFlagGatesApply(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if !a.probeOnlyActive() {
t.Fatal("probe-only must be active from the master flag")
}
if a.sbSwitched {
t.Fatal("switchover latched under probe-only")
}
if a.singboxRunning() {
t.Fatal("sing-box started under probe-only")
}
if a.lastSingboxHash != "" {
t.Fatalf("lastSingboxHash = %q, want untouched (empty)", a.lastSingboxHash)
}
if _, err := os.Stat(singboxConfigPath()); !os.IsNotExist(err) {
t.Fatalf("applied config file exists under probe-only (stat err = %v)", err)
}
}
// The local agent.json preset gates even against a master that
// still serves a payload (and predates the flag): with no core
// binary on disk, not even a core fetch may be attempted.
func TestProbeOnlyLocalPresetSkipsCoreFetch(t *testing.T) {
binDir := t.TempDir() // deliberately no fake sing-box binary
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
coreSrv := fakeCoreServer(t, &hits, http.StatusOK)
defer coreSrv.Close()
useCoreBase(t, coreSrv.URL)
m := newProbeMaster(t) // probe_only absent from its responses
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if hits.Load() != 0 {
t.Fatalf("core fetch attempted %d times under local probe-only preset", hits.Load())
}
if a.sbSwitched || a.singboxRunning() {
t.Fatal("core engaged under local probe-only preset")
}
}
// Turning the flag on while sing-box is running retires the core
// exactly once; turning it off again reactivates the core from the
// unchanged hash (the poll's not-running path), without the
// bookkeeping having been dirtied in between.
func TestProbeOnlyRetiresRunningSingboxAndHealsBack(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("initial fetch: %v", err)
}
if !a.singboxRunning() || !a.sbSwitched {
t.Fatal("precondition: sing-box must be running after the normal poll")
}
hashBefore := a.lastSingboxHash
m.probeOnly.Store(true)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("probe-only fetch: %v", err)
}
if a.singboxRunning() {
t.Fatal("sing-box must be retired when probe-only turns on")
}
if !a.sbSwitched {
t.Fatal("sbSwitched must stay latched (bookkeeping untouched by probe-only)")
}
if a.lastSingboxHash != hashBefore {
t.Fatalf("lastSingboxHash dirtied: %q -> %q", hashBefore, a.lastSingboxHash)
}
// A second probe-only poll is a no-op, not an error.
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("second probe-only fetch: %v", err)
}
m.probeOnly.Store(false)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("heal-back fetch: %v", err)
}
if !a.singboxRunning() {
t.Fatal("sing-box must restart once probe-only turns off (hash unchanged)")
}
}
// Same retirement for a legacy xray core that predates the
// switchover: probe-only stops it and does not start sing-box.
func TestProbeOnlyRetiresLegacyXray(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if a.xrayProc != nil {
t.Fatal("xray core must be retired under probe-only")
}
if a.singboxRunning() || a.sbSwitched {
t.Fatal("sing-box must not engage under probe-only")
}
}
// The traffic cycle is an explicit no-op under probe-only: even
// with a core process somehow alive, nothing is collected or posted.
func TestProbeOnlyTrafficCycleIsNoop(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
// Put a core under the agent directly (bypassing the poll gate)
// to prove the traffic cycle itself refuses to touch it.
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
t.Fatalf("applySingboxConfig: %v", err)
}
if !a.singboxRunning() {
t.Fatal("precondition: fake sing-box must be running")
}
if err := a.collectAndReportTraffic(); err != nil {
t.Fatalf("collectAndReportTraffic under probe-only: %v", err)
}
if n := m.trafficHit.Load(); n != 0 {
t.Fatalf("traffic posted %d times under probe-only", n)
}
}
+6
View File
@@ -1,3 +1,9 @@
//go:build linux
// This file tests the Linux sampler's /proc and /sys parsers
// (probe_linux.go); the shared machinery has its own coverage and the
// Windows sampler's pure helpers live in probe_windows_test.go.
package agent
import (
+530
View File
@@ -0,0 +1,530 @@
//go:build windows
package agent
// probe_windows.go is the Windows sampler for the probe collector,
// feeding the same Facts/Metrics contract and accounting discipline
// as the Linux sampler (see probe.go) from the Win32 surface:
//
// - CPU%: GetSystemTimes differenced through the shared cpuPercent
// (kernel time includes idle, exactly like /proc/stat jiffies);
// - memory: GlobalMemoryStatusEx, used = TotalPhys - AvailPhys
// (the free(1) "available" column's spirit); swap is the pagefile
// pair (TotalPageFile/AvailPageFile);
// - disk: fixed drives only (GetDriveType == DRIVE_FIXED), summed
// like df: used = total - free bytes (GetDiskFreeSpaceEx's
// TotalFree, NOT the caller-available figure, which discounts
// quotas the way f_bavail discounts the ext reserve);
// - network: GetIfTable2 rows filtered to OperStatus Up, ifType
// Ethernet (6) or IEEE80211 (71), non-loopback. This approximates
// the Linux three-layer filter: Windows exposes no stacking
// topology in the MIB, but tunnel/virtual adapters report their
// own ifTypes (tunnel 131, loopback 24, ...), so an allowlist of
// physical media lands on the same counted set in practice. The
// cost of the difference: a physical-medium bond/virtual switch
// vNIC presenting as Ethernet IS counted here even where Linux
// layer 3 would have skipped it;
// - boot identity: the registry MachineGuid stands in for the
// kernel boot_id; the counted-set FNV fingerprint is shared, so a
// NIC change re-baselines exactly like on Linux;
// - uptime: GetTickCount64 (via x/sys' DurationSinceBoot wrapper);
// - load averages, TCP/UDP connection counts have no exported
// x/sys/windows (v0.47.0) source — GetSystemTimes-style raw
// counters exist for CPU/memory, but the package exports no
// GetTcpTable2/GetUdpTable2 wrapper, so per the independent-
// degradation discipline Load stays zero and TCP/UDP stay 0
// until a wrapper exists;
// - every call fails soft to zeros, like an unreadable /proc file.
// Optional procedures are resolved through source gates
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
// missing export, and trimmed images do ship DLLs without the
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
// collection (V2.2.1 ①). A missing procedure now disables just
// its source, and collectMetrics additionally guards each source
// so a panic anywhere degrades that source alone.
import (
"net"
"os"
"runtime"
"strings"
"time"
"unsafe"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// kernel32 procs x/sys/windows v0.47.0 does not wrap, plus the
// iphlpapi pair this file calls directly (gated below, so x/sys'
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
// missing export — is deliberately not used).
var (
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
)
// Availability gates for the optional procedures above. A LazyProc
// must never be Call()ed before its gate has proven the export
// exists: Call must-finds, so a missing procedure is a process-killing
// panic, not an error.
var (
gateSystemTimes sourceGate
gateMemoryStatus sourceGate
gateIfTable sourceGate
)
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
// the export WITHOUT the mustFind panic baked into Call/Addr.
func procProbe(p *windows.LazyProc) func() error {
return func() error { return p.Find() }
}
func sampleFacts() *ProbeFacts {
memTotal, _, swapTotal, _ := winMemory()
diskTotal, _ := winDiskUsage()
v4, v6 := winAddresses()
return &ProbeFacts{
Hostname: winHostname(),
OS: winOSName(),
Kernel: winKernelBuild(),
Arch: runtime.GOARCH,
Virt: winVirt(),
CPUName: winCPUName(),
CPUCores: uint32(runtime.NumCPU()),
MemTotal: memTotal,
SwapTotal: swapTotal,
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// memSnapshot / diskSnapshot pack the multi-value samplers so each
// can run as one guarded source.
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
type diskSnapshot struct{ total, used uint64 }
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
// Every source runs guarded: a panic in one (a Win32 surface the
// gates did not foresee) zeroes that source alone instead of
// killing the agent on its first collection.
mem := guardedCall("memory", func() memSnapshot {
total, used, swapTotal, swapUsed := winMemory()
return memSnapshot{total, used, swapTotal, swapUsed}
})
disk := guardedCall("disk", func() diskSnapshot {
total, used := winDiskUsage()
return diskSnapshot{total, used}
})
rows := guardedCall("interfaces", winIfaceRows)
all := make([]netCounters, 0, len(rows))
counted := make([]netCounters, 0, len(rows))
for _, r := range rows {
c := netCounters{name: r.name, rx: r.rx, tx: r.tx}
all = append(all, c)
if winIfaceCounts(r) {
counted = append(counted, c)
}
}
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(all, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
rxRate, txRate := probeColl.netRate(counted, time.Now())
return &ProbeMetrics{
BootID: bootEpoch(winMachineGUID(), names),
Uptime: guardedCall("uptime", func() uint64 {
return uint64(windows.DurationSinceBoot().Seconds())
}),
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
Load: [3]float32{},
MemTotal: mem.total,
MemUsed: mem.used,
SwapTotal: mem.swapTotal,
SwapUsed: mem.swapUsed,
DiskTotal: disk.total,
DiskUsed: disk.used,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: 0, // no exported x/sys wrapper (see header)
UDP: 0, // no exported x/sys wrapper (see header)
Procs: guardedCall("processes", winProcCount),
}
}
// --- registry helpers --------------------------------------------------------
func regGetString(root registry.Key, path, name string) string {
k, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
v, _, err := k.GetStringValue(name)
if err != nil {
return ""
}
return strings.TrimSpace(v)
}
func winMachineGUID() string {
return regGetString(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, "MachineGuid")
}
func winOSName() string {
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
name := regGetString(registry.LOCAL_MACHINE, key, "ProductName")
if name == "" {
return "Windows"
}
// DisplayVersion ("23H2") disambiguates: ProductName still says
// "Windows 10" on most Windows 11 installs.
if dv := regGetString(registry.LOCAL_MACHINE, key, "DisplayVersion"); dv != "" {
name += " " + dv
}
return name
}
func winKernelBuild() string {
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
if b := regGetString(registry.LOCAL_MACHINE, key, "CurrentBuild"); b != "" {
return b
}
return "unknown"
}
func winCPUName() string {
if n := regGetString(registry.LOCAL_MACHINE,
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString"); n != "" {
return n
}
return "unknown"
}
// winVirt is best-effort: the BIOS strings Windows itself surfaces.
// No match reads "unknown" (Windows guests of a hypervisor that hides
// them are indistinguishable from bare metal this way).
func winVirt() string {
const bios = `HARDWARE\DESCRIPTION\System\BIOS`
hay := strings.ToLower(regGetString(registry.LOCAL_MACHINE, bios, "SystemManufacturer") +
" " + regGetString(registry.LOCAL_MACHINE, bios, "SystemProductName"))
for _, m := range []struct{ word, name string }{
{"vmware", "vmware"}, {"virtualbox", "virtualbox"}, {"qemu", "kvm"},
{"kvm", "kvm"}, {"xen", "xen"}, {"virtual machine", "hyper-v"},
{"amazon ec2", "kvm"}, {"google", "kvm"}, {"bochs", "bochs"},
} {
if strings.Contains(hay, m.word) {
return m.name
}
}
return "unknown"
}
func winHostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "unknown"
}
// --- cpu / memory --------------------------------------------------------------
// filetime100ns flattens a FILETIME (100ns ticks) for the shared differ.
func filetime100ns(ft windows.Filetime) uint64 {
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
}
// winCPUSample reads the cumulative system times as one cpuSample:
// total = kernel + user (kernel already includes idle, like jiffies).
func winCPUSample() *cpuSample {
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
return nil
}
var idle, kernel, user windows.Filetime
r, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)))
if r == 0 {
return nil
}
return &cpuSample{
total: filetime100ns(kernel) + filetime100ns(user),
idle: filetime100ns(idle),
}
}
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
// usedFromTotalAvail is the used-column discipline as a pure
// function: used = total - avail, saturating at 0.
func usedFromTotalAvail(total, avail uint64) uint64 {
if avail > total {
return 0
}
return total - avail
}
func winMemory() (total, used, swapTotal, swapUsed uint64) {
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
return 0, 0, 0, 0
}
var ms memoryStatusEx
ms.Length = uint32(unsafe.Sizeof(ms))
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
if r == 0 {
return 0, 0, 0, 0
}
return ms.TotalPhys, usedFromTotalAvail(ms.TotalPhys, ms.AvailPhys),
ms.TotalPageFile, usedFromTotalAvail(ms.TotalPageFile, ms.AvailPageFile)
}
// --- disk (df口径) -----------------------------------------------------------
// driveSpace is one fixed drive's GetDiskFreeSpaceEx result.
type driveSpace struct{ total, free uint64 }
// sumDriveSpace aggregates fixed drives the df way: used = total -
// free (the caller-available figure is deliberately not consulted).
func sumDriveSpace(ds []driveSpace) (total, used uint64) {
for _, d := range ds {
total += d.total
used += usedFromTotalAvail(d.total, d.free)
}
return total, used
}
// splitDriveStrings decodes the GetLogicalDriveStrings multi-string
// buffer (NUL-separated roots, double-NUL terminated). Pure, so the
// layout is unit-testable off-device.
func splitDriveStrings(buf []uint16) []string {
var out []string
start := 0
for i, c := range buf {
if c != 0 {
continue
}
if i == start {
break // double NUL: end of the list
}
out = append(out, windows.UTF16ToString(buf[start:i]))
start = i + 1
}
return out
}
// fixedDriveRoots lists roots ("C:\") of DRIVE_FIXED drives.
func fixedDriveRoots() []string {
n, err := windows.GetLogicalDriveStrings(0, nil)
if err != nil || n == 0 {
return nil
}
buf := make([]uint16, n)
n, err = windows.GetLogicalDriveStrings(n, &buf[0])
if err != nil {
return nil
}
var roots []string
for _, s := range splitDriveStrings(buf[:n]) {
p, err := windows.UTF16PtrFromString(s)
if err != nil {
continue
}
if windows.GetDriveType(p) == windows.DRIVE_FIXED {
roots = append(roots, s)
}
}
return roots
}
func winDiskUsage() (total, used uint64) {
var spaces []driveSpace
for _, root := range fixedDriveRoots() {
p, err := windows.UTF16PtrFromString(root)
if err != nil {
continue
}
var freeToCaller, totalBytes, totalFree uint64
if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil {
continue
}
spaces = append(spaces, driveSpace{total: totalBytes, free: totalFree})
}
return sumDriveSpace(spaces)
}
// --- network: counted set ------------------------------------------------------
// winIfaceRow is the filter-relevant slice of one MIB_IF_ROW2.
type winIfaceRow struct {
name string
ifType uint32
operUp bool
rx, tx uint64
}
// winIfaceCounts applies the counted-set rule to one row (see the
// file header for how it approximates the Linux three-layer filter).
func winIfaceCounts(r winIfaceRow) bool {
if !r.operUp {
return false
}
if r.ifType == windows.IF_TYPE_SOFTWARE_LOOPBACK {
return false
}
return r.ifType == windows.IF_TYPE_ETHERNET_CSMACD ||
r.ifType == windows.IF_TYPE_IEEE80211
}
// winIfaceRows reads the full MIB_IF_TABLE2 once; collectMetrics
// derives both the all-interfaces list (for the operator pin) and the
// counted set from it.
func winIfaceRows() []winIfaceRow {
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
// without FreeMibIfTable2 would leak the system-allocated table
// on every poll, and either export missing must degrade the
// source to zero rows instead of panicking — the trimmed Tianyi
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
if err := procGetIfTable2Ex.Find(); err != nil {
return err
}
return procFreeMibIfTable2.Find()
}) {
return nil
}
var table *windows.MibIfTable2
r, _, _ := procGetIfTable2Ex.Call(
uintptr(windows.MibIfTableNormal),
uintptr(unsafe.Pointer(&table)))
if r != 0 || table == nil {
return nil
}
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
n := int(table.NumEntries)
if n == 0 {
return nil
}
rows := unsafe.Slice(&table.Table[0], n)
out := make([]winIfaceRow, 0, n)
for i := range rows {
r := &rows[i]
out = append(out, winIfaceRow{
name: windows.UTF16ToString(r.Alias[:]),
ifType: r.Type,
operUp: r.OperStatus == windows.IfOperStatusUp,
rx: r.InOctets,
tx: r.OutOctets,
})
}
return out
}
// --- processes -----------------------------------------------------------------
func winProcCount() uint32 {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return 0
}
defer windows.CloseHandle(snap)
var pe windows.ProcessEntry32
pe.Size = uint32(unsafe.Sizeof(pe))
if err := windows.Process32First(snap, &pe); err != nil {
return 0
}
var n uint32 = 1
for {
if err := windows.Process32Next(snap, &pe); err != nil {
break
}
n++
}
return n
}
// --- addresses -------------------------------------------------------------------
// winAddresses picks one address per family from up, non-loopback
// interfaces: public preferred over private, like the Linux picker —
// only without its /proc inputs (no operstate file, no temporary-
// address flags; net.Interfaces' own flags do the loopback cut).
func winAddresses() (string, string) {
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
var cands []net.IP
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsLoopback() {
continue
}
cands = append(cands, ip)
}
}
pick := func(v6 bool) string {
var best net.IP
for _, ip := range cands {
if (ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = ip
continue
}
if isPublicIP(ip) && !isPublicIP(best) {
best = ip
}
}
if best == nil {
return ""
}
return best.String()
}
return pick(false), pick(true)
}
+99
View File
@@ -0,0 +1,99 @@
//go:build windows
package agent
import (
"testing"
"golang.org/x/sys/windows"
)
// The Windows sampler's pure helpers: the Win32 calls themselves need
// a real machine, but the accounting math layered on their results is
// pinned here.
func TestFiletime100nsAndCPUSampleDiff(t *testing.T) {
lo := windows.Filetime{LowDateTime: 0x00000005, HighDateTime: 0x00000002}
if got := filetime100ns(lo); got != 0x200000005 {
t.Fatalf("filetime100ns = %#x, want 0x200000005", got)
}
// The shared differ over Windows-style counters (kernel includes
// idle): idle advanced by the full window -> 0% busy; then a
// window where only a quarter of the time is idle -> 75%.
c := &probeCollector{}
first := &cpuSample{total: 1000, idle: 400}
if got := c.cpuPercent(first); got != 0 {
t.Fatalf("first sample = %v, want 0 (baseline only)", got)
}
idleAll := &cpuSample{total: 2000, idle: 1400}
if got := c.cpuPercent(idleAll); got != 0 {
t.Fatalf("all-idle window = %v, want 0", got)
}
busy := &cpuSample{total: 3000, idle: 1650}
if got := c.cpuPercent(busy); got != 75 {
t.Fatalf("busy window = %v, want 75", got)
}
}
func TestUsedFromTotalAvail(t *testing.T) {
if got := usedFromTotalAvail(100, 30); got != 70 {
t.Fatalf("used = %d, want 70", got)
}
if got := usedFromTotalAvail(100, 100); got != 0 {
t.Fatalf("used = %d, want 0", got)
}
if got := usedFromTotalAvail(100, 130); got != 0 {
t.Fatalf("used saturates, got %d", got)
}
}
func TestSumDriveSpaceDfSemantics(t *testing.T) {
total, used := sumDriveSpace([]driveSpace{
{total: 1000, free: 250},
{total: 2000, free: 2000},
{total: 500, free: 900}, // inconsistent source clamps to full
})
if total != 3500 || used != 1250 {
t.Fatalf("sumDriveSpace = %d/%d, want 3500/1250", total, used)
}
}
func TestSplitDriveStrings(t *testing.T) {
mk := func(ss ...string) []uint16 {
var out []uint16
for _, s := range ss {
for _, r := range s {
out = append(out, uint16(r))
}
out = append(out, 0)
}
return append(out, 0)
}
got := splitDriveStrings(mk(`C:\`, `D:\`))
if len(got) != 2 || got[0] != `C:\` || got[1] != `D:\` {
t.Fatalf("splitDriveStrings = %q, want [C:\\ D:\\]", got)
}
if got := splitDriveStrings(mk()); len(got) != 0 {
t.Fatalf("empty buffer = %q, want none", got)
}
}
func TestWinIfaceCountsRule(t *testing.T) {
cases := []struct {
name string
row winIfaceRow
want bool
}{
{"up ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: true}, true},
{"up wifi", winIfaceRow{ifType: windows.IF_TYPE_IEEE80211, operUp: true}, true},
{"down ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: false}, false},
{"loopback", winIfaceRow{ifType: windows.IF_TYPE_SOFTWARE_LOOPBACK, operUp: true}, false},
{"tunnel", winIfaceRow{ifType: 131, operUp: true}, false},
{"ppp", winIfaceRow{ifType: 23, operUp: true}, false},
}
for _, tc := range cases {
if got := winIfaceCounts(tc.row); got != tc.want {
t.Errorf("%s: winIfaceCounts = %v, want %v", tc.name, got, tc.want)
}
}
}
+22
View File
@@ -52,6 +52,15 @@ var execSelf = func(path string) error {
return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ())
}
// selfUpdateSupported reports whether this platform can consume a
// master update intent. Windows agents are the standalone
// xui-agent-windows binary (see cmd/xui-agent-windows), serviced by
// re-running install-agent.ps1: the release assets an intent points
// at are the panel's Linux binaries, so an intent delivered to a
// Windows node is acknowledged and skipped, never downloaded.
// A package variable so tests can exercise the skip on any OS.
var selfUpdateSupported = runtime.GOOS != "windows"
func (a *Agent) setUpdateReport(st agentUpdateStatus) {
a.updateMu.Lock()
cp := st
@@ -82,6 +91,19 @@ func (a *Agent) maybeHandleUpdate(intent *updateIntent) {
a.updateMu.Unlock()
return
}
if !selfUpdateSupported {
// Windows (see selfUpdateSupported): log once per intent and
// mark it handled so the poll does not re-log it every cycle.
// No report is filed — the master-side intent simply ages out,
// and the node updates by re-running install-agent.ps1.
a.updateMu.Lock()
if intent.RequestedAt != a.updateHandled {
a.updateHandled = intent.RequestedAt
logger.Infof("agent update to %s offered but skipped on %s: Windows agents update by re-running install-agent.ps1", intent.Target, runtime.GOOS)
}
a.updateMu.Unlock()
return
}
a.updateMu.Lock()
if a.updateRunning || intent.RequestedAt == a.updateHandled ||
(intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) {
+33
View File
@@ -159,6 +159,39 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
}
}
func TestAgentUpdateSkippedOnUnsupportedOS(t *testing.T) {
orig := selfUpdateSupported
selfUpdateSupported = false
t.Cleanup(func() { selfUpdateSupported = orig })
var hits atomic.Int32
srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits)
defer srv.Close()
a, exe := newUpdateTestAgent(t, srv.URL)
intent := &updateIntent{RequestedAt: 46, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"}
a.maybeHandleUpdate(intent)
// A repeated delivery of the same intent is also a no-op.
a.maybeHandleUpdate(intent)
if hits.Load() != 0 {
t.Fatalf("release server hit %d times on an unsupported OS, want 0", hits.Load())
}
got, _ := os.ReadFile(exe)
if string(got) != "old-binary" {
t.Fatalf("exe = %q, want untouched old-binary", got)
}
a.updateMu.Lock()
handled, running := a.updateHandled, a.updateRunning
a.updateMu.Unlock()
if handled != 46 || running {
t.Fatalf("handled=%d running=%v, want handled=46 running=false", handled, running)
}
if rep := a.currentUpdateReport(); rep != nil {
t.Fatalf("report = %+v, want none for a skipped intent", rep)
}
}
var errExecBoom = &execBoomError{}
type execBoomError struct{}
+16 -2
View File
@@ -125,16 +125,30 @@ func GetDisplayVersion() string {
return defaultDisplayVersion
}
// bareDisplayVersion is GetDisplayVersion without a single leading
// V/v. Release builds inject the number either bare ("2.1.0") or in
// the master's V-form ("V2.1.0"); the letter-prefixed getters below
// must compose from the bare form so the letter never doubles
// ("VV2.1.0" / "AV2.1.0" — the latter is what agent rows showed when
// the V-form was injected).
func bareDisplayVersion() string {
v := GetDisplayVersion()
if len(v) > 1 && (v[0] == 'V' || v[0] == 'v') {
return v[1:]
}
return v
}
// GetMasterDisplayVersion is the master's display version ("V1.0.0"),
// shown in the panel's version slot.
func GetMasterDisplayVersion() string {
return "V" + GetDisplayVersion()
return "V" + bareDisplayVersion()
}
// GetAgentDisplayVersion is the agent's display version ("A1.0.0"),
// reported in agent telemetry and shown on agent node rows.
func GetAgentDisplayVersion() string {
return "A" + GetDisplayVersion()
return "A" + bareDisplayVersion()
}
// ParseDisplayVersion parses a display version into its three numeric
+14
View File
@@ -75,6 +75,20 @@ func TestDisplayVersionDefaultsAndOverride(t *testing.T) {
if got := GetAgentDisplayVersion(); got != "A1.0.1" {
t.Fatalf("injected agent display version = %q, want A1.0.1", got)
}
// A release build may inject the number in the master's V-form:
// neither getter may double its letter ("VV1.0.2" / "AV1.0.2").
displayVersion = "V1.0.2"
if got := GetMasterDisplayVersion(); got != "V1.0.2" {
t.Fatalf("V-injected master display version = %q, want V1.0.2", got)
}
if got := GetAgentDisplayVersion(); got != "A1.0.2" {
t.Fatalf("V-injected agent display version = %q, want A1.0.2", got)
}
displayVersion = "v1.0.3"
if got := GetAgentDisplayVersion(); got != "A1.0.3" {
t.Fatalf("v-injected agent display version = %q, want A1.0.3", got)
}
}
func TestParseDisplayVersion(t *testing.T) {
+7
View File
@@ -108,6 +108,13 @@ type AgentNode struct {
SeenIPv6 string `json:"seenIpv6"`
SeenIPAt int64 `json:"seenIpAt"`
// ProbeOnly marks the node as probe-only (V2.1.0): the master serves
// no core config for it and the agent runs no core at all — only the
// probe channels (heartbeat, fast report, latency tasks) stay live.
// Set from the panel's node form; the agent can also preset it in
// its local agent.json, either source turns the mode on.
ProbeOnly bool `json:"probeOnly"`
// One-click update (item 20): the master records an update intent here;
// the agent picks it up from its config poll, downloads + verifies +
// swaps its binary, restarts itself and reports the outcome back on
+35 -3
View File
@@ -957,7 +957,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
case "tls":
applyShareTLSParams(stream, params)
case "reality":
applyShareRealityParams(stream, params)
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
default:
params["security"] = "none"
}
@@ -1010,7 +1010,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
case "tls":
applyShareTLSParams(stream, params)
case "reality":
applyShareRealityParams(stream, params)
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow {
params["flow"] = client.Flow
}
@@ -1700,7 +1700,30 @@ func hysteriaPinHex(pin string) string {
return pin
}
func applyShareRealityParams(stream map[string]any, params map[string]string) {
// singboxFragmentServerName returns the tls.server_name of the
// inbound's stored sing-box fragment — the ONE name the sing-box
// REALITY server actually serves. The converter puts exactly one
// name there (realitySettings.serverNames[0], see internal/singbox)
// while the legacy settings may list dozens. "" when the inbound has
// no fragment, the fragment is unparseable, or it carries no server
// name; callers then keep the historical behaviour.
func singboxFragmentServerName(singboxConfig string) string {
if strings.TrimSpace(singboxConfig) == "" {
return ""
}
var fragment map[string]any
if err := json.Unmarshal([]byte(singboxConfig), &fragment); err != nil {
return ""
}
if tlsBlock, ok := fragment["tls"].(map[string]any); ok {
if name, ok := tlsBlock["server_name"].(string); ok {
return strings.TrimSpace(name)
}
}
return ""
}
func applyShareRealityParams(stream map[string]any, fragmentSNI string, params map[string]string) {
params["security"] = "reality"
realitySetting, _ := stream["realitySettings"].(map[string]any)
realitySettings, _ := searchKey(realitySetting, "settings")
@@ -1710,6 +1733,15 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
params["sni"], _ = sNames[random.Num(len(sNames))].(string)
}
}
// The fragment's server_name wins over the pick above: xray
// serves the whole serverNames list, so a random entry was
// safe there, but the sing-box core serves only the
// fragment's single name — any other entry is refused as an
// invalid REALITY connection (the PPF-L3 outage: 29 listed
// names, core serving only the first).
if fragmentSNI != "" {
params["sni"] = fragmentSNI
}
if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok {
params["pbk"], _ = pbkValue.(string)
}
+54
View File
@@ -89,6 +89,60 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
}
}
// Scope D: with several serverNames, the generated link's sni must be
// the sing-box fragment's tls.server_name — the only name the sing-box
// REALITY server serves (the converter stores serverNames[0] there).
// The historical behaviour picked a uniformly random list entry per
// generation, which xray tolerated (it serves the whole list) but
// sing-box refuses as an invalid connection. Without a fragment the
// sni still comes from the list.
func TestGenVlessLink_RealitySniPrefersSingboxFragment(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["amd.example.com","connect-sit.amd.example.com","www.amd.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`
s := &SubService{}
inbound := shareLinkInbound(stream)
inbound.SingboxConfig = `{"type":"vless","tag":"in","listen":"::","listen_port":443,` +
`"tls":{"enabled":true,"server_name":"amd.example.com",` +
`"reality":{"enabled":true,"short_id":["ab12cd"]}}}`
// The losing branch is a random pick: sample enough generations
// that a regression to it cannot survive by luck (2/3 wrong per
// draw; 30 draws make a false pass ~2e-6).
for i := 0; i < 30; i++ {
link := s.genVlessLink(inbound, "user")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
if got := u.Query().Get("sni"); got != "amd.example.com" {
t.Fatalf("draw %d: sni = %q, want the fragment's amd.example.com\nlink: %s", i, got, link)
}
}
// No fragment (or an unusable one): historical behaviour — the
// sni is some entry of the serverNames list.
for _, frag := range []string{"", "{not json", `{"type":"vless"}`} {
inbound.SingboxConfig = frag
link := s.genVlessLink(inbound, "user")
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
switch got := u.Query().Get("sni"); got {
case "amd.example.com", "connect-sit.amd.example.com", "www.amd.example.com":
default:
t.Fatalf("fragment %q: sni = %q, want a serverNames entry", frag, got)
}
}
}
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
// with distinct subIds.
func realityTwoClientInbound() *model.Inbound {
+33 -9
View File
@@ -626,11 +626,16 @@ func probeStatIface(agentID int64) string {
}
// probeReport ingests one fast report (V2.0.1): a probe metrics
// block and nothing else. It deliberately does NOT touch the
// agent's last-seen stamp — liveness stays the 60 s heartbeat's
// signal, and the traffic ledger's unseen-boot rule keeps measuring
// reboots against heartbeat sightings exactly as before. Agents on
// older masters never see this endpoint (404) and simply keep
// block and nothing else. Since V2.0.3 it also touches the agent's
// last-seen stamp, like every other report channel: the hub's
// themes gate each batch on the node's last_seen being newer than
// the last one they applied, so a metrics-only channel that never
// advanced the stamp froze the public cards between the 10 s
// traffic reports. Liveness now means "any report from the agent"
// — all channels come from the same process — and the traffic
// ledger's unseen-boot rule is unaffected: Record still receives
// the pre-touch stamp, so gaps measure exactly as before. Agents
// on older masters never see this endpoint (404) and simply keep
// reporting metrics on the heartbeat; masters accept metrics on
// either channel.
func (a *AgentController) probeReport(c *gin.Context) {
@@ -644,9 +649,13 @@ func (a *AgentController) probeReport(c *gin.Context) {
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
}
_ = c.ShouldBindJSON(&req)
// The last-seen stamp as it stood before this report (see the
// heartbeat handler): captured before touchAgent overwrites it.
prevLastSeen := agent.LastSeen
a.touchAgent(uint(agent.Id), "")
if req.ProbeMetrics != nil {
a.updateAgentProbe(uint(agent.Id), nil, req.ProbeMetrics)
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), agent.LastSeen)
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
}
jsonObj(c, gin.H{"message": "ok"}, nil)
}
@@ -662,22 +671,37 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return
}
// Probe-only nodes (V2.1.0) get no core payload at all: the build
// is skipped entirely, so a broken data model on such a node can
// never 500 its poll, and the probe keys + update intent below
// ride along exactly as for normal nodes. singbox_config stays
// null and the hash empty, which agents read as "no payload".
var sbRaw json.RawMessage
sbHash := ""
if !agent.ProbeOnly {
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
if sbErr != nil {
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
c.JSON(500, gin.H{"message": "failed to build sing-box config"})
return
}
sbRaw, mErr := json.Marshal(sbCfg)
raw, mErr := json.Marshal(sbCfg)
if mErr != nil {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return
}
sbRaw = raw
sbSum := sha256.Sum256(sbRaw)
sbHash = hex.EncodeToString(sbSum[:])
}
payload := gin.H{
"singbox_config": json.RawMessage(sbRaw),
"singbox_config_hash": hex.EncodeToString(sbSum[:]),
"singbox_config": sbRaw,
"singbox_config_hash": sbHash,
// Probe-only (V2.1.0): the per-node flag rides every poll so
// toggling it in the panel takes effect without touching the
// node. Always present from this master version.
"probe_only": agent.ProbeOnly,
// Item 7 B4: the agent's probe task set rides the existing
// config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while
@@ -18,9 +18,10 @@ import (
)
// V2.0.1 probe batch, master side: the fast-report endpoint ingests
// metrics without touching liveness (last-seen stays the
// heartbeat's), and the config poll carries the cadence + the
// node's counted-interface pin.
// metrics, and the config poll carries the cadence + the node's
// counted-interface pin. Since V2.0.3 the fast report also stamps
// last-seen (the hub's themes gate batches on its freshness); it
// originally did not — see the probeReport handler comment.
func TestAgentFastReportAndConfigKeys(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
@@ -71,6 +72,12 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
`"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}`
// Age the stamp first so the fast report's touch is observable
// even within the same millisecond as the heartbeat's.
stale := seenAfterHeartbeat - 120_000
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil {
t.Fatalf("age last-seen: %v", err)
}
if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK {
t.Fatalf("probe-report: %d (%s)", code, body)
}
@@ -78,8 +85,8 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
t.Errorf("fast report metrics not stored: %+v", row)
}
if row.LastSeen != seenAfterHeartbeat {
t.Errorf("fast report moved last-seen: %d -> %d (liveness is the heartbeat's signal)", seenAfterHeartbeat, row.LastSeen)
if row.LastSeen <= stale {
t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, row.LastSeen)
}
// Unauthorized callers are refused.
@@ -0,0 +1,125 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// V2.1.0: a probe-only agent's config poll carries the flag and NO
// core payload (singbox_config null, hash empty), while the probe
// keys and a pending update intent still ride along — a probe-only
// node must stay reachable for probes and one-click agent updates.
// The control agent on a normal node keeps receiving its payload.
func TestConfigProbeOnlyWithholdsCorePayload(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
seed := func(name, token string, probeOnly bool) {
node := &model.Node{Name: name, Address: "10.0.9.4"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
agent := &model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: token[:12],
Name: name,
NodeID: &node.Id,
AgentVersion: "A2.0.3",
ProbeOnly: probeOnly,
}
if probeOnly {
agent.UpdateRequestedAt = time.Now().Unix()
agent.UpdateState = "pending"
agent.UpdateTarget = "A2.1.0"
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
inbound := &model.Inbound{
Enable: true,
Port: 443,
Protocol: model.VLESS,
Tag: "inbound-" + name,
NodeID: &node.Id,
Settings: `{"clients":[],"decryption":"none","encryption":"none","fallbacks":[]}`,
StreamSettings: `{"network":"tcp","security":"reality","realitySettings":` +
`{"serverNames":["www.amd.com"],"shortIds":["ab12cd34"],"privateKey":"FAKEPRIV",` +
`"settings":{"publicKey":"FAKEPUB","fingerprint":"chrome","spiderX":"/"}}}`,
SingboxConfig: `{"type":"vless","tag":"inbound-` + name + `","listen_port":443,` +
`"tls":{"enabled":true,"server_name":"www.amd.com","reality":{"enabled":true,` +
`"handshake":{"server":"www.amd.com","server_port":443},"private_key":"FAKEPRIV",` +
`"short_id":["ab12cd34"]}},"users":[]}`,
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
}
const probeToken = "probe-only-token-0000000000000000000001"
const normalToken = "probe-normal-token-00000000000000000001"
seed("probe-node", probeToken, true)
seed("normal-node", normalToken, false)
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
get := func(token string) map[string]any {
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("config response not JSON: %v", err)
}
return resp
}
resp := get(probeToken)
if resp["probe_only"] != true {
t.Fatalf("probe_only = %v, want true", resp["probe_only"])
}
if cfg, present := resp["singbox_config"]; !present || cfg != nil {
t.Fatalf("singbox_config = %v (present=%v), want JSON null", cfg, present)
}
if h := resp["singbox_config_hash"]; h != "" {
t.Fatalf("singbox_config_hash = %v, want empty", h)
}
if _, present := resp["probe_tasks"]; !present {
t.Fatal("probe_tasks missing: probe-only nodes still run latency probes")
}
if _, present := resp["probe_fast_interval"]; !present {
t.Fatal("probe_fast_interval missing on probe-only config")
}
upd, _ := resp["agent_update"].(map[string]any)
if upd == nil || upd["target"] != "A2.1.0" {
t.Fatalf("agent_update = %v, want the pending intent (probe-only nodes still update)", resp["agent_update"])
}
resp = get(normalToken)
if resp["probe_only"] != false {
t.Fatalf("probe_only = %v, want false for a normal node", resp["probe_only"])
}
if cfg, _ := resp["singbox_config"].(map[string]any); cfg == nil {
t.Fatalf("normal node lost its singbox payload: %v", resp["singbox_config"])
}
if h, _ := resp["singbox_config_hash"].(string); h == "" {
t.Fatal("normal node singbox_config_hash empty")
}
}
+18 -1
View File
@@ -8,6 +8,9 @@ import (
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -167,11 +170,25 @@ func (a *NodeController) add(c *gin.Context) {
if isAgent {
view.NodeType = "agent"
nodeID := view.Id
token, _, err := issueAgentToken(&nodeID, n.Name)
token, agentID, err := issueAgentToken(&nodeID, n.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
// Probe-only (V2.1.0): preset the flag on the freshly issued
// agent row when the create form asked for it, so the very
// first config poll already serves no core payload. A failed
// preset only logs — the node and token are usable, and the
// flag can still be flipped from the edit form.
if n.ProbeOnly != nil && *n.ProbeOnly {
if err := database.GetDB().Model(&model.AgentNode{}).
Where("id = ?", agentID).
Update("probe_only", true).Error; err != nil {
logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err)
} else {
view.ProbeOnly = true
}
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
"node": view,
"agentToken": token,
+19 -4
View File
@@ -52,7 +52,10 @@ type probeMetaForm struct {
IPv4Pin string `json:"ipv4Pin"`
IPv6Pin string `json:"ipv6Pin"`
// V2.0.1: traffic calibration (GB, either sign) and the counted
// interface pin ("" = automatic).
// interface pin ("" = automatic). Since V2.2.1 ⑥b the calibration
// fields are ONE-SHOT deltas: a non-zero value is folded into the
// stored standing correction on save (see probe.MergeCalDelta);
// zero/absent applies nothing.
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
StatIface string `json:"statIface"`
@@ -75,8 +78,11 @@ func metaToForm(m probe.Meta) probeMetaForm {
CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin,
TrafficCalUpGB: m.TrafficCalUpGB,
TrafficCalDownGB: m.TrafficCalDownGB,
// One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
// boxes always start empty; the standing correction folded
// into the stored row is never echoed back into them.
TrafficCalUpGB: 0,
TrafficCalDownGB: 0,
StatIface: m.StatIface,
}
}
@@ -150,7 +156,16 @@ func (pc *ProbeMetaController) putMeta(c *gin.Context) {
jsonMsg(c, msg, errRequest)
return
}
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
// Calibration is one-shot (V2.2.1 ⑥b): fold the submitted deltas
// into the stored standing correction; all other fields replace.
meta := form.toMeta(int64(agent.Id))
if existing, found, err := probe.MetaFor(int64(agent.Id)); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
} else if found {
meta = probe.MergeCalDelta(existing, meta)
}
if err := probe.SaveMeta(meta); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
}
+12 -9
View File
@@ -211,10 +211,12 @@ func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// liveWS is the hub's browser stream: a snapshot frame every 2 s,
// text by default, binary gzip frames for the anonymous audience when
// opened with ?gzip. Audience eligibility is re-checked every tick —
// a socket outlives both the session and the public-page switch.
// liveWS is the hub's browser stream: a snapshot frame on the
// operator's fast-report cadence (V2.0.2; the hub's fixed 2 s tick
// made a 1 s agent cadence invisible up front), text by default,
// binary gzip frames for the anonymous audience when opened with
// ?gzip. Audience eligibility is re-checked every tick — a socket
// outlives both the session and the public-page switch.
func (pc *PublicProbeController) liveWS(c *gin.Context) {
authed := probeAuthed(c) && !fromOtherOrigin(c)
if !authed && !service.ProbePublicPageEnabled() {
@@ -280,18 +282,19 @@ func (pc *PublicProbeController) liveWS(c *gin.Context) {
return conn.WriteMessage(frameType, frame) == nil
}
// The first frame goes out immediately (hub's interval ticks at
// once), then one per 2 s tick.
// The first frame goes out immediately; later frames follow the
// fast-report cadence, re-read after every frame so a settings
// change lands on a live socket without a reconnect.
if !send() {
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
timer := time.NewTimer(time.Duration(service.ProbeFastIntervalSec()) * time.Second)
select {
case <-done:
timer.Stop()
return
case <-ticker.C:
case <-timer.C:
if !send() {
return
}
+3 -1
View File
@@ -323,7 +323,9 @@ func TestProbePublicContract(t *testing.T) {
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
var node map[string]any
_ = json.Unmarshal(nodeBody, &node)
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
// V2.2.1 ⑦: the public view's name is the public remark when one
// is set (the seed sets "shown publicly"), not the node name.
if node["id"] != float64(pubID) || node["name"] != "shown publicly" || node["online"] != true ||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
t.Fatalf("anon node values wrong: %v", node)
@@ -0,0 +1,63 @@
package controller
import (
"net/http/httptest"
"strings"
"testing"
"time"
ws "github.com/gorilla/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// V2.0.2: /api/ws frame cadence follows the operator's fast-report
// interval (probe_fast_interval) instead of the hub's fixed 2 s tick.
// A mid-stream settings change re-paces a live socket: the timer
// already in flight still fires on the old cadence, the next one is
// armed from the re-read setting.
func TestProbeWebSocketCadenceFollowsFastInterval(t *testing.T) {
rig := newProbeTestRig(t)
_, _ = rig.seedAgent(t, "CadenceNode", true)
rig.setSetting(t, "public_page", "on")
rig.setSetting(t, "probe_fast_interval", "1")
service.InvalidateProbeSnapshot()
srv := httptest.NewServer(rig.engine)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
readAt := func() time.Time {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("ws read: %v", err)
}
return time.Now()
}
_ = readAt() // first frame: immediate
prev := readAt()
for i := 0; i < 2; i++ {
now := readAt()
if gap := now.Sub(prev); gap < 500*time.Millisecond || gap > 2500*time.Millisecond {
t.Fatalf("frame gap at 1 s cadence: %v", gap)
}
prev = now
}
// Change to 3 s mid-stream: one in-flight 1 s frame, then the
// stream settles onto the new cadence without a reconnect.
rig.setSetting(t, "probe_fast_interval", "3")
prev = readAt()
now := readAt()
if gap := now.Sub(prev); gap < 2200*time.Millisecond || gap > 6*time.Second {
t.Fatalf("frame gap after change to 3 s: %v", gap)
}
}
+78
View File
@@ -680,6 +680,17 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
// Probe-only (V2.1.0) lives on the bound agent rows, not the
// node row: flip every binding of this node in the same
// transaction, so the flag and the node edit land together.
// Nil means the form didn't touch the switch.
if isAgent && req.ProbeOnly != nil {
if err := tx.Model(&model.AgentNode{}).
Where("node_id = ?", id).
Update("probe_only", *req.ProbeOnly).Error; err != nil {
return err
}
}
return s.MarkNodeDirtyTx(tx, id)
}); err != nil {
return err
@@ -1223,7 +1234,74 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
}
// agentOnlineWindow is how fresh an agent heartbeat must be for its
// node to read online — the same 180s the heartbeat job expires agent
// nodes by (agentHeartbeatStaleAfter in internal/web/job: max(3
// heartbeat intervals, 120s), so two lost heartbeats don't flap).
const agentOnlineWindow = 180 * time.Second
var errAgentOffline = errors.New("agent node offline: no fresh heartbeat")
// agentHeartbeatVerdict is the pure decision behind the agent-node
// probe: a fresh stamp reads online with the heartbeat AGE as the
// latency (there is no dial to time); a stale or never-seen stamp
// reads offline with LatencyMs -1. LastError stays empty either way —
// the answer comes from the heartbeat, not from a dial failure.
func agentHeartbeatVerdict(lastSeenMs int64, now time.Time) (HeartbeatPatch, bool) {
patch := HeartbeatPatch{LatencyMs: -1}
if lastSeenMs <= 0 {
return patch, false
}
patch.LastHeartbeat = lastSeenMs / 1000
if age := now.Sub(time.UnixMilli(lastSeenMs)); age <= agentOnlineWindow {
patch.LastHeartbeat = now.Unix()
patch.LatencyMs = int(age / time.Millisecond)
return patch, true
}
return patch, false
}
// agentNodeProbe answers a probe for an agent-managed node from the
// bound agent's last_seen stamp (Unix ms, the unit the agent
// controller stamps). ok=false means the node has no bound agent — or
// the lookup failed — and the caller takes the dial path. The stored
// telemetry fields carry through unchanged (same as the heartbeat
// job's agent-expiry patch); only the error is dropped, so a ⚡ click
// also heals the bogus dial errors recorded before this change.
func (s *NodeService) agentNodeProbe(n *model.Node) (patch HeartbeatPatch, err error, ok bool) {
var lastSeen *int64 // MAX() over zero rows is NULL
qerr := database.GetDB().Model(&model.AgentNode{}).
Where("node_id = ?", n.Id).
Select("MAX(last_seen)").
Scan(&lastSeen).Error
if qerr != nil || lastSeen == nil {
return HeartbeatPatch{}, nil, false
}
patch, online := agentHeartbeatVerdict(*lastSeen, time.Now())
patch.XrayVersion = n.XrayVersion
patch.PanelVersion = n.PanelVersion
patch.Guid = n.Guid
patch.CpuPct = n.CpuPct
patch.MemPct = n.MemPct
patch.UptimeSecs = n.UptimeSecs
patch.NetUp = n.NetUp
patch.NetDown = n.NetDown
patch.XrayState = n.XrayState
patch.XrayError = n.XrayError
if !online {
return patch, errAgentOffline, true
}
return patch, nil, true
}
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
// Agent-managed nodes have no panel to dial — their address row
// is a placeholder ("agent") — so a probe answers from the agent
// heartbeat instead of dialing a dead URL and persisting a
// LastError the agent heartbeat never clears (V2.2.1 ②).
if patch, err, ok := s.agentNodeProbe(n); ok {
return patch, err
}
proxyURL := ""
if n.OutboundTag != "" {
if mgr := runtime.GetManager(); mgr != nil {
@@ -0,0 +1,70 @@
package service
import (
"testing"
"time"
)
// V2.2.1 ②: the ⚡ probe for an agent-managed node is synthesized
// from the agent's heartbeat stamp instead of dialing the placeholder
// address. These tests pin the pure verdict: fresh stamp → online
// with the heartbeat age as latency; stale / never-seen → offline
// with LatencyMs -1; LastError empty in every case (a dial error must
// never be recorded for an agent node again).
func TestAgentHeartbeatVerdictFreshIsOnlineWithAgeLatency(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
lastSeenMs := now.Add(-30 * time.Second).UnixMilli()
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
if !online {
t.Fatal("30s-old heartbeat must read online")
}
if patch.LatencyMs != 30_000 {
t.Fatalf("LatencyMs = %d, want heartbeat age 30000", patch.LatencyMs)
}
if patch.LastHeartbeat != now.Unix() {
t.Fatalf("LastHeartbeat = %d, want now %d", patch.LastHeartbeat, now.Unix())
}
if patch.LastError != "" {
t.Fatalf("LastError = %q, want empty", patch.LastError)
}
}
func TestAgentHeartbeatVerdictWindowEdge(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow).UnixMilli(), now); !online {
t.Fatal("heartbeat exactly at the window edge must read online")
}
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow-time.Second).UnixMilli(), now); online {
t.Fatal("heartbeat past the window must read offline")
}
}
func TestAgentHeartbeatVerdictStaleIsOffline(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
lastSeenMs := now.Add(-10 * time.Minute).UnixMilli()
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
if online {
t.Fatal("10min-old heartbeat must read offline")
}
if patch.LatencyMs != -1 {
t.Fatalf("LatencyMs = %d, want -1", patch.LatencyMs)
}
if patch.LastHeartbeat != lastSeenMs/1000 {
t.Fatalf("LastHeartbeat = %d, want the truthful last-seen %d", patch.LastHeartbeat, lastSeenMs/1000)
}
if patch.LastError != "" {
t.Fatalf("LastError = %q, want empty", patch.LastError)
}
}
func TestAgentHeartbeatVerdictNeverSeenIsOffline(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
patch, online := agentHeartbeatVerdict(0, now)
if online {
t.Fatal("never-seen agent must read offline")
}
if patch.LatencyMs != -1 || patch.LastHeartbeat != 0 || patch.LastError != "" {
t.Fatalf("patch = %+v, want zero stamp, latency -1, no error", patch)
}
}
@@ -7,6 +7,28 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// The panel-version cell must read as the bare A-form across the
// fleet: pre-fix agents report the doubled "AV2.1.0" and are repaired
// at view assembly; legacy build versions keep their "agent " prefix.
func TestAgentVersionCell(t *testing.T) {
cases := map[string]string{
"A2.1.0": "A2.1.0",
"A1.0.0": "A1.0.0",
"AV2.1.0": "A2.1.0",
"AV2.0.1": "A2.0.1",
"agent AV2.0.3": "A2.0.3",
"3.7.0-agent.1": "agent 3.7.0-agent.1",
"agent A2.1.0": "A2.1.0",
" A2.1.0 ": "A2.1.0",
"some-dev-build": "agent some-dev-build",
}
for in, want := range cases {
if got := agentVersionCell(in); got != want {
t.Errorf("agentVersionCell(%q) = %q, want %q", in, got, want)
}
}
}
// P13: annotateAgentTelemetry overlays the agent-reported heartbeat
// telemetry onto agent node views only — panel version becomes
// "agent <version>", xray version/uptime/latency/CPU/mem/IPs take the
+39 -8
View File
@@ -40,6 +40,11 @@ type NodeView struct {
// traditional nodes and for agents with no known address yet.
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
// ProbeOnly marks an agent node as probe-only (V2.1.0): no core
// runs on it, only the probe channels. Overlaid from the bound
// agent row like the update bookkeeping below; false (and absent)
// for traditional nodes and unflagged agents.
ProbeOnly bool `json:"probeOnly,omitempty" example:"false"`
// Agent one-click update bookkeeping (item 20), agent nodes only:
// the last intent's state/target and, on failure, its error.
AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"`
@@ -185,6 +190,30 @@ func annotateNodeTypes(views []*NodeView) {
}
}
// agentVersionCell renders one agent's reported version for the
// node list's panel-version cell, uniformly across the fleet:
// - current agents report the A-series display version and are
// shown bare ("A2.1.0");
// - agents built while releases injected the V-form number report
// the doubled "AV2.1.0" (see config.GetAgentDisplayVersion) and
// are repaired to the bare A-form here, so those nodes read
// correctly without waiting for their next agent update;
// - pre-display-version agents report the internal build version
// and keep the historical "agent " prefix.
func agentVersionCell(reported string) string {
s := strings.TrimSpace(reported)
s = strings.TrimPrefix(s, "agent ")
if len(s) > 2 && (s[0] == 'A' || s[0] == 'a') && (s[1] == 'V' || s[1] == 'v') {
if _, ok := config.ParseDisplayVersion(s[1:]); ok {
return "A" + s[2:]
}
}
if _, ok := config.ParseDisplayVersion(s); ok && strings.HasPrefix(s, "A") {
return s
}
return "agent " + s
}
// annotateAgentTelemetry overlays the latest heartbeat telemetry reported
// by each node's bound agent onto its view (P13): xray version, the
// "agent <version>" panel-version label, host uptime, node->master latency,
@@ -225,6 +254,10 @@ func annotateAgentTelemetry(views []*NodeView) {
// its first telemetry report: an agent that has only registered
// already has a master-observed source address.
v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag)
// Probe-only (V2.1.0) is a stored flag, not telemetry: it must
// show even before the agent's first report, so it is overlaid
// here, ahead of the telemetry early-exit below.
v.ProbeOnly = ag.ProbeOnly
if ag.TelemetryAt == 0 {
continue
}
@@ -235,14 +268,7 @@ func annotateAgentTelemetry(views []*NodeView) {
v.SingboxVersion = ag.SingboxVersion
}
if ag.AgentVersion != "" {
// New agents report the A-series display version, shown
// verbatim; older agents report the internal build version
// and keep the historical "agent " prefix.
if _, ok := config.ParseDisplayVersion(ag.AgentVersion); ok && strings.HasPrefix(ag.AgentVersion, "A") {
v.PanelVersion = ag.AgentVersion
} else {
v.PanelVersion = "agent " + ag.AgentVersion
}
v.PanelVersion = agentVersionCell(ag.AgentVersion)
v.AgentUpdateState = ag.UpdateState
v.AgentUpdateError = ag.UpdateError
v.AgentUpdateTarget = ag.UpdateTarget
@@ -278,6 +304,11 @@ type NodeMutationRequest struct {
// agent nodes. Agent nodes skip the panel reachability probe and the API
// token requirement; an agent token is issued on creation instead.
NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"`
// ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A
// pointer so updates can tell "leave the flag alone" (nil) from an
// explicit on/off; applied to the bound agent rows, agent nodes
// only. On create it presets the flag on the freshly issued agent.
ProbeOnly *bool `json:"probeOnly,omitempty" form:"probeOnly"`
}
func (r *NodeMutationRequest) validateCredentials(create bool) error {
@@ -0,0 +1,80 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// V2.1.0: the node form's probeOnly switch persists onto the bound
// agent rows via UpdateFromRequest (nil leaves the flag untouched),
// and the list view overlays it from the binding.
func TestNodeProbeOnlyUpdateAndView(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
node := &model.Node{Id: 7601, Name: "probe-svc", Enable: true, Status: "online"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
ag := &model.AgentNode{TokenHash: "hash-probe-svc", Name: "probe-svc", NodeID: &node.Id}
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
svc := &NodeService{}
truth := true
lies := false
base := func() *NodeMutationRequest {
return &NodeMutationRequest{
Name: "probe-svc", NodeType: "agent", Enable: true,
Scheme: "https", BasePath: "/",
}
}
// Nil: flag untouched (still false).
if err := svc.UpdateFromRequest(node.Id, base()); err != nil {
t.Fatalf("update (nil): %v", err)
}
var row model.AgentNode
if err := db.First(&row, ag.Id).Error; err != nil {
t.Fatal(err)
}
if row.ProbeOnly {
t.Fatal("nil probeOnly must not set the flag")
}
// True: flag set on the agent row, and the annotated view shows it.
req := base()
req.ProbeOnly = &truth
if err := svc.UpdateFromRequest(node.Id, req); err != nil {
t.Fatalf("update (true): %v", err)
}
if err := db.First(&row, ag.Id).Error; err != nil {
t.Fatal(err)
}
if !row.ProbeOnly {
t.Fatal("probeOnly=true not persisted on the agent row")
}
view, err := svc.GetViewById(node.Id)
if err != nil {
t.Fatalf("GetViewById: %v", err)
}
if !view.ProbeOnly {
t.Fatal("view probeOnly = false, want true from the binding")
}
// False: explicit off wins over the stored value.
req = base()
req.ProbeOnly = &lies
if err := svc.UpdateFromRequest(node.Id, req); err != nil {
t.Fatalf("update (false): %v", err)
}
if err := db.First(&row, ag.Id).Error; err != nil {
t.Fatal(err)
}
if row.ProbeOnly {
t.Fatal("probeOnly=false not persisted on the agent row")
}
}
+27 -2
View File
@@ -164,13 +164,38 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
// display version. An unparseable tag (not a V-series release) yields ""
// so the UI falls back to the raw fields.
func displayUpdateFields(latestTag string) (string, bool) {
return displayUpdateFieldsFor(latestTag, config.GetMasterDisplayVersion(), config.GetBaseVersion())
}
// displayUpdateFieldsFor is displayUpdateFields with the running
// build's versions as inputs. When the DISPLAY comparison cannot
// judge — the running build's display version is itself unparseable,
// the "VV2.1.0" double-letter builds — the verdict falls back to the
// raw version comparison instead of reporting "no update": the
// display path used to swallow a real update entirely (the panel
// showed no prompt while /releases/latest happily served the newer
// tag, and the native updater refused to run). A parseable display
// version keeps the segment rule exactly as before.
func displayUpdateFieldsFor(latestTag, currentDisplay, currentRaw string) (string, bool) {
parts, ok := config.ParseDisplayVersion(latestTag)
if !ok {
return "", false
}
disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2])
cmp, ok := config.CompareDisplayVersion(disp, config.GetMasterDisplayVersion())
return disp, ok && cmp > 0
cmp, ok := config.CompareDisplayVersion(disp, currentDisplay)
if !ok {
// Raw fallback: judge the latest tag against the running
// build's raw base version, parsed with the same tolerant
// display parser (bare and V/A forms both parse). An
// unparseable raw base yields no verdict → false. This is
// deliberately NOT isNewerVersion: on this fork's uppercase-V
// tags that helper degrades to string inequality (its
// normalizer strips only a lowercase "v"), which would report
// a phantom update whenever latest merely differs in form.
rawCmp, rawOk := config.CompareDisplayVersion(disp, currentRaw)
return disp, rawOk && rawCmp > 0
}
return disp, cmp > 0
}
// devChannelActive reports whether self-update should track the rolling dev
@@ -25,6 +25,42 @@ func TestDisplayUpdateFields(t *testing.T) {
}
}
// V2.2.1 ③: when the running build's display version is unparseable
// (the "VV2.1.0" double-letter builds), the display comparison cannot
// judge and the verdict must fall back to the raw version comparison
// instead of swallowing the update. A parseable display version keeps
// the segment rule — the raw string plays no part there.
func TestDisplayUpdateFieldsForFallback(t *testing.T) {
// Unparseable display "VV2.1.0", newer latest → available via raw.
if disp, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "2.1.0"); disp != "V2.2.0" || !avail {
t.Fatalf("VV display, newer latest = %q, %v; want V2.2.0, true", disp, avail)
}
// Unparseable display, latest equal to the raw base → not
// available, in both raw forms (bare and V-prefixed).
if disp, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "2.1.0"); disp != "V2.1.0" || avail {
t.Fatalf("VV display, same latest (bare raw) = %q, %v; want V2.1.0, false", disp, avail)
}
if _, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "V2.1.0"); avail {
t.Fatal("VV display, same latest (V raw) must not report an update")
}
// Unparseable display AND unparseable raw base → no verdict, false.
if _, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "3.7.0-agent.1"); avail {
t.Fatal("unparseable raw base must yield no update verdict")
}
// Parseable display: segment rule decides, raw base is ignored
// even when it disagrees.
if disp, avail := displayUpdateFieldsFor("V2.2.0", "V2.1.0", "9.9.9"); disp != "V2.2.0" || !avail {
t.Fatalf("parseable display, newer = %q, %v; want V2.2.0, true", disp, avail)
}
if _, avail := displayUpdateFieldsFor("V2.1.0", "V2.1.0", "1.0.0"); avail {
t.Fatal("parseable display, equal versions must not report an update (raw base must not leak in)")
}
// Unparseable latest tag still yields "" so the UI uses raw fields.
if disp, avail := displayUpdateFieldsFor("3.7.0-agent.1", "VV2.1.0", "2.1.0"); disp != "" || avail {
t.Fatalf("legacy tag = %q, %v; want empty, false", disp, avail)
}
}
func TestServiceUnitFromCgroup(t *testing.T) {
content := "0::/system.slice/x-ui.service\n"
if got := serviceUnitFromCgroup(content); got != "x-ui" {
+27
View File
@@ -34,6 +34,9 @@ type Meta struct {
// corrections in GB (V2.0.1), either sign: added to the counted
// uplink (tx) / downlink (rx) figures wherever the ledger is
// read — panel statistics, the public page and traffic alerts.
// The stored values are the STANDING accumulated correction;
// the edit form submits one-shot deltas that MergeCalDelta folds
// into them (V2.2.1 ⑥b).
TrafficCalUpGB float64
TrafficCalDownGB float64
// StatIface pins the one interface whose counters the agent
@@ -138,6 +141,30 @@ func (s *Store) UpsertMeta(m Meta) error {
return err
}
// MergeCalDelta folds a probe-meta form submission into the stored
// row under the one-shot calibration semantics (V2.2.1 ⑥b): the
// form's calibration fields are a correction TO APPLY NOW, not the
// new standing value. A non-zero submitted value adds onto the stored
// standing correction; a zero field (an absent field is the same
// zero — the wire type cannot distinguish them, and both mean
// "nothing to apply") keeps the stored value untouched. Every other
// field takes the submitted value, as UpsertMeta's full replace
// does. Paired with the form clearing its calibration inputs after a
// successful save, a later unrelated save can never re-apply an old
// correction.
func MergeCalDelta(existing, submitted Meta) Meta {
out := submitted
out.TrafficCalUpGB = existing.TrafficCalUpGB
if submitted.TrafficCalUpGB != 0 {
out.TrafficCalUpGB += submitted.TrafficCalUpGB
}
out.TrafficCalDownGB = existing.TrafficCalDownGB
if submitted.TrafficCalDownGB != 0 {
out.TrafficCalDownGB += submitted.TrafficCalDownGB
}
return out
}
func boolToInt(b bool) int64 {
if b {
return 1
@@ -0,0 +1,143 @@
package probe
import (
"path/filepath"
"testing"
"time"
)
// V2.2.1 ⑥: GB entries convert on the binary convention (1024^3)
// every formatter uses, stored decimal-era limits are rescaled once,
// and calibration submissions are one-shot deltas folded into the
// standing correction — never re-applied by a later unrelated save.
func TestCalBytesBinaryConvention(t *testing.T) {
if got := calBytes(3000); got != 3000*(1<<30) {
t.Fatalf("calBytes(3000) = %d, want %d (3000 GiB)", got, 3000*(1<<30))
}
if got := calBytes(0); got != 0 {
t.Fatalf("calBytes(0) = %d, want 0", got)
}
if got := calBytes(-1.5); got != -1610612736 {
t.Fatalf("calBytes(-1.5) = %d, want -1610612736", got)
}
}
func TestGBConventionMigrationRescalesLimitsOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "probe.db")
st, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
// Seed a legacy row as a pre-V2.2.1 database holds it — 3000 GB
// typed at the decimal convention = 3.0e12 stored bytes — and
// strike the migration mark so the next open sees an unmigrated
// database.
if _, err := st.db.Exec(`INSERT INTO probe_meta (agent_id, traffic_limit) VALUES (9, 3000000000000)`); err != nil {
t.Fatalf("seed legacy limit: %v", err)
}
if _, err := st.db.Exec(`DELETE FROM probe_setting WHERE key = ?`, gbConventionKey); err != nil {
t.Fatalf("strike mark: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close: %v", err)
}
st2, err := Open(path)
if err != nil {
t.Fatalf("reopen (migration run): %v", err)
}
m, found, err := st2.Meta(9)
if err != nil || !found {
t.Fatalf("meta after migration: found=%v err=%v", found, err)
}
// 3.0e12 x 1024^3/1e9 = 3000 x 2^30 exactly (~3.221e12).
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
t.Fatalf("migrated limit = %d, want %d", m.TrafficLimit, want)
}
if err := st2.Close(); err != nil {
t.Fatalf("close 2: %v", err)
}
st3, err := Open(path)
if err != nil {
t.Fatalf("reopen 2: %v", err)
}
defer st3.Close()
m, found, err = st3.Meta(9)
if err != nil || !found {
t.Fatalf("meta after second open: found=%v err=%v", found, err)
}
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
t.Fatalf("limit after second open = %d, want %d (no double rescale)", m.TrafficLimit, want)
}
}
func TestOneShotCalibrationSaveFlow(t *testing.T) {
st := newTestStore(t)
now := time.Now()
book(t, st, 5, "boot-a", 1000, 1000, now, nil, 0) // baseline: books 0
book(t, st, 5, "boot-a", 3000, 2000, now, nil, 0) // books rx 2000, tx 1000
// First save carries a +1 GB uplink correction delta.
submitted := DefaultMeta(5)
submitted.TrafficCalUpGB = 1
merged := MergeCalDelta(DefaultMeta(5), submitted)
if merged.TrafficCalUpGB != 1 {
t.Fatalf("standing after first fold = %v, want 1", merged.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged); err != nil {
t.Fatalf("upsert 1: %v", err)
}
after1, ok, err := st.TrafficFor(5)
if err != nil || !ok {
t.Fatalf("traffic after save 1: ok=%v err=%v", ok, err)
}
if want := int64(1000) + (1 << 30); after1.TotalTx != want {
t.Fatalf("TotalTx after save 1 = %d, want %d", after1.TotalTx, want)
}
if after1.TotalRx != 2000 {
t.Fatalf("TotalRx after save 1 = %d, want 2000 (uncalibrated)", after1.TotalRx)
}
// Second save changes another field; the (cleared) form submits
// zero calibration — the calibrated figure must not move again.
stored, found, err := st.Meta(5)
if err != nil || !found {
t.Fatalf("stored meta: found=%v err=%v", found, err)
}
sub2 := DefaultMeta(5)
sub2.GroupName = "g2"
merged2 := MergeCalDelta(stored, sub2)
if merged2.TrafficCalUpGB != 1 {
t.Fatalf("standing after zero-delta save = %v, want 1 (unchanged)", merged2.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged2); err != nil {
t.Fatalf("upsert 2: %v", err)
}
after2, ok, err := st.TrafficFor(5)
if err != nil || !ok {
t.Fatalf("traffic after save 2: ok=%v err=%v", ok, err)
}
if after2.TotalTx != after1.TotalTx {
t.Fatalf("TotalTx moved on an unrelated save: %d -> %d (double-applied calibration)", after1.TotalTx, after2.TotalTx)
}
// A further non-zero delta accumulates exactly once more.
sub3 := DefaultMeta(5)
sub3.TrafficCalUpGB = 0.5
merged3 := MergeCalDelta(merged2, sub3)
if merged3.TrafficCalUpGB != 1.5 {
t.Fatalf("standing after second delta = %v, want 1.5", merged3.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged3); err != nil {
t.Fatalf("upsert 3: %v", err)
}
after3, _, err := st.TrafficFor(5)
if err != nil {
t.Fatalf("traffic after save 3: %v", err)
}
if want := int64(1000) + calBytes(1.5); after3.TotalTx != want {
t.Fatalf("TotalTx after save 3 = %d, want %d", after3.TotalTx, want)
}
}
+4 -2
View File
@@ -91,8 +91,10 @@ func TestTrafficCalibrationAppliedAndFloored(t *testing.T) {
t.Fatalf("AllTraffic: %v", err)
}
tr := all[5]
if tr.TotalRx != 3_500_000_000 || tr.MonthRx != 3_500_000_000 || tr.DayRx != 3_500_000_000 {
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3500000000 each", tr.TotalRx, tr.MonthRx, tr.DayRx)
// V2.2.1 ⑥: calibration converts on the binary convention —
// +0.5 GB = 0.5 x 2^30 = 536870912 B (was 5e8 at the old 1e9).
if tr.TotalRx != 3_536_870_912 || tr.MonthRx != 3_536_870_912 || tr.DayRx != 3_536_870_912 {
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3536870912 each", tr.TotalRx, tr.MonthRx, tr.DayRx)
}
if tr.TotalTx != 0 || tr.MonthTx != 0 || tr.DayTx != 0 {
t.Errorf("tx figures after -2 GB calibration = %d/%d/%d, want 0 (floored)", tr.TotalTx, tr.MonthTx, tr.DayTx)
+43
View File
@@ -213,9 +213,52 @@ func Open(path string) (*Store, error) {
db.Close()
return nil, fmt.Errorf("probe schema migration: %w", err)
}
if err := migrateGBConvention(db); err != nil {
db.Close()
return nil, fmt.Errorf("probe gb migration: %w", err)
}
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
}
// gbConventionKey is the probe_setting mark recording that the
// V2.2.1 ⑥ traffic-limit rescale has been applied to this database.
const gbConventionKey = "gb_1024_migrated_v1"
// migrateGBConvention rescales stored probe_meta traffic limits from
// the old decimal entry convention to the unified binary one, once.
// Limits are typed in GB and were stored as GB × 1e9 bytes, while
// every byte formatter (panel, public page) divides by 1024^3 — a
// 3000 GB limit displayed as 2.73 TB instead of 2.93 TB. The GB the
// operator typed is the intent, so stored bytes are scaled by
// 1024^3/1e9 = 1.073741824 and now read back correctly under the
// binary conversion. Only traffic_limit is byte-denominated:
// calibration values are stored in GB (their byte conversion,
// calBytes, carries the fix) and price/sort/reset-day are unitless —
// neither is touched here. The probe_setting mark makes the rescale
// run exactly once per database.
func migrateGBConvention(db *sql.DB) error {
var v string
err := db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, gbConventionKey).Scan(&v)
if err == nil {
return nil // already applied
}
if err != sql.ErrNoRows {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE probe_meta SET traffic_limit = CAST(ROUND(traffic_limit * 1.073741824) AS INTEGER) WHERE traffic_limit > 0`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, '1')`, gbConventionKey); err != nil {
return err
}
return tx.Commit()
}
// metaColumnMigrations are the probe_meta columns added after the
// table's first release; CREATE TABLE IF NOT EXISTS never retrofits
// them onto an existing database, so they are added here, once, by
+6 -2
View File
@@ -332,9 +332,13 @@ func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) {
// AllTraffic returns every agent's ledger in one query, gated as in
// TrafficFor — the bulk read port for B3's node list.
// calBytes converts a GB calibration value to bytes on the panel's
// 1 GB = 1e9 B convention (the same one the traffic limit uses).
// binary convention, 1 GB = 1024^3 B — the convention every byte
// formatter in the panel and on the public page uses, and the one
// the traffic limit's GB entry converts with (V2.2.1 ⑥; the decimal
// 1e9 stood here before, which made a 3000 GB limit read as 2.73 TB
// instead of 2.93 TB wherever bytes were formatted).
func calBytes(gb float64) int64 {
return int64(math.Round(gb * 1e9))
return int64(math.Round(gb * (1 << 30)))
}
// applyCalibration shifts a ledger row by the operator's per-node
+19 -4
View File
@@ -29,9 +29,11 @@ import (
// 150 s absorbs one lost beat plus jitter.
const ProbeOnlineThresholdMs = 150_000
// ProbeSnapshotTTL mirrors the hub's 1900 ms frame reuse: just under
// the 2 s push interval, so a frame is built at most once per tick.
const ProbeSnapshotTTL = 1900 * time.Millisecond
// ProbeSnapshotTTL mirrors the hub's frame reuse, shortened in
// V2.0.3 from the hub's 1900 ms: just under the fastest (1 s) push
// cadence, so a 1 s stream still gets a fresh frame every tick
// instead of repeating the previous one half the time.
const ProbeSnapshotTTL = 900 * time.Millisecond
// ---- public-surface settings (main settings KV, hub key names) ----
@@ -266,6 +268,19 @@ func expiresInDays(expires *string, now time.Time) any {
return days
}
// probeDisplayName is the name a node carries in the public views
// (V2.2.1 ⑦): the operator's public remark when one is set — it is
// the label written for public consumption — else the node name. The
// contract field stays "name"; themes and the public API keep their
// shape, only the value's source changes. The panel's internal node
// list is a different surface and keeps the plain node name.
func probeDisplayName(name, publicRemark string) string {
if r := strings.TrimSpace(publicRemark); r != "" {
return r
}
return name
}
// buildProbeNodeView renders one node for one audience. The public
// shape is built by whitelist (hub node_view): address, hostname and
// the private remark exist only in the admin (full) rendering.
@@ -296,7 +311,7 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
view := map[string]any{
"id": a.Id,
"name": src.name,
"name": probeDisplayName(src.name, m.PublicRemark),
"country": m.CountryPin,
"group": m.GroupName,
"public_remark": m.PublicRemark,
@@ -0,0 +1,40 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// V2.2.1 ⑦: the public views' node name is the operator's public
// remark when one is set, and the node name otherwise — the panel's
// own list (a different surface) is untouched.
func TestProbeDisplayNamePrefersPublicRemark(t *testing.T) {
if got := probeDisplayName("node-name", "东京 01"); got != "东京 01" {
t.Fatalf("with remark = %q, want the remark", got)
}
if got := probeDisplayName("node-name", ""); got != "node-name" {
t.Fatalf("empty remark = %q, want node name", got)
}
if got := probeDisplayName("node-name", " "); got != "node-name" {
t.Fatalf("whitespace-only remark = %q, want node name", got)
}
}
func TestBuildProbeNodeViewNameSource(t *testing.T) {
withRemark := probeNodeSource{
name: "internal-name",
meta: probe.Meta{PublicRemark: "公开名", Public: true},
}
if got := buildProbeNodeView(withRemark, false)["name"]; got != "公开名" {
t.Fatalf("anonymous view name = %v, want the public remark", got)
}
if got := buildProbeNodeView(withRemark, true)["name"]; got != "公开名" {
t.Fatalf("admin view name = %v, want the public remark", got)
}
without := probeNodeSource{name: "internal-name", meta: probe.Meta{Public: true}}
if got := buildProbeNodeView(without, false)["name"]; got != "internal-name" {
t.Fatalf("view name without remark = %v, want the node name", got)
}
}
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -1051,6 +1051,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "传统面板节点",
"nodeTypeAgent": "Agent 轻量节点",
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
"probeOnly": "纯探针",
"probeOnlyHint": "纯探针模式:该节点不运行任何代理核心,只保留探针功能(心跳、指标与延迟探测)。",
"agentTokenTitle": "Agent 节点安装",
"agentTokenCreated": "节点 {{name}} 创建成功",
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",