6 Commits
Author SHA1 Message Date
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
40 changed files with 2636 additions and 829 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: '/', basePath: '/',
apiToken: '', apiToken: '',
hasStoredToken: false, hasStoredToken: false,
probeOnly: false,
enable: true, enable: true,
allowPrivateAddress: false, allowPrivateAddress: false,
tlsVerifyMode: 'verify', tlsVerifyMode: 'verify',
@@ -83,6 +84,9 @@ export default function NodeFormModal({
const [testResult, setTestResult] = useState<ProbeResult | null>(null); const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const [agentToken, setAgentToken] = useState<string | null>(null); const [agentToken, setAgentToken] = useState<string | null>(null);
const [agentNodeName, setAgentNodeName] = useState(''); 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(''); const [agentMasterUrl, setAgentMasterUrl] = useState('');
// Edit mode (item 19): the stored install material for an existing agent // Edit mode (item 19): the stored install material for an existing agent
// node, fetched from the master; null until loaded / for non-agent nodes. // 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 inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional'; const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
const isAgent = nodeType === 'agent'; const isAgent = nodeType === 'agent';
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true }); const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector); // Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -178,10 +183,13 @@ export default function NodeFormModal({
const base = const base =
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` + `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
` --master ${editMasterUrl}`; ` --master ${editMasterUrl}`;
return shownInstall.hasToken const cmd = shownInstall.hasToken
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}` ? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
: `${base} --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( const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -211,6 +219,9 @@ export default function NodeFormModal({
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
outboundTag: values.outboundTag || '', 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; if (token) payload.apiToken = token;
return payload; return payload;
} }
@@ -296,6 +307,7 @@ export default function NodeFormModal({
if (agentMode && mode === 'add' && obj?.agentToken) { if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken); setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim()); setAgentNodeName(result.data.name.trim());
setAgentProbeOnly(result.data.probeOnly ?? false);
// Default the master URL to the address this panel is being // Default the master URL to the address this panel is being
// viewed through — origin AND base path. Dropping the base // viewed through — origin AND base path. Dropping the base
// path made the agent's registration 404. User can edit it. // 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 // one would produce a command that can never register, so withhold it
// (and say why) instead of letting the user copy a dead command. // (and say why) instead of letting the user copy a dead command.
const installCmd = agentMasterUrl 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 ( return (
@@ -430,11 +442,21 @@ export default function NodeFormModal({
)} )}
{isAgent && ( {isAgent && (
<Row gutter={16}> <Row gutter={16}>
<Col xs={24}> <Col xs={24} md={12}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked"> <FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch /> <Switch />
</FormField> </FormField>
</Col> </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> </Row>
)} )}
+9 -1
View File
@@ -495,12 +495,20 @@ export default function NodeList({
{ {
// Core version (V2.0.1 ②): agent rows run sing-box and show // Core version (V2.0.1 ②): agent rows run sing-box and show
// its version; traditional rows keep showing the xray one. // 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'), title: t('pages.nodes.coreVersion'),
dataIndex: 'xrayVersion', dataIndex: 'xrayVersion',
align: 'center', align: 'center',
render: (_value, record) => render: (_value, record) =>
record.nodeType === 'agent' 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 || '-', : record.xrayVersion || '-',
}, },
{ {
+5
View File
@@ -56,6 +56,9 @@ export const NodeRecordSchema = z
// P7: node kind, annotated by the backend for nodes registered by an // P7: node kind, annotated by the backend for nodes registered by an
// agent. Absent/traditional for classic panel-to-panel nodes. // agent. Absent/traditional for classic panel-to-panel nodes.
nodeType: z.enum(['traditional', 'agent']).optional(), 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. // P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
// Rendered as plain text in the address column for agent rows. // Rendered as plain text in the address column for agent rows.
ipv4: z.string().optional(), ipv4: z.string().optional(),
@@ -101,6 +104,8 @@ export const NodeFormSchema = z
// Agent nodes use an agent token instead of a panel API token. // Agent nodes use an agent token instead of a panel API token.
apiToken: z.string().trim(), apiToken: z.string().trim(),
hasStoredToken: z.boolean().optional().default(false), 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(), enable: z.boolean(),
allowPrivateAddress: z.boolean(), allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']), 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.0"
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."
+24 -4
View File
@@ -1,6 +1,8 @@
#!/bin/bash #!/bin/bash
# 3x-ui-agent 节点一键安装脚本 # 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 运行 # 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
# #
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数—— # 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
@@ -28,12 +30,13 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行" [[ $EUID -ne 0 ]] && error "请用 root 运行"
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑" command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
MASTER=""; TOKEN=""; NAME="" MASTER=""; TOKEN=""; NAME=""; PROBE_ONLY=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--master) MASTER="$2"; shift 2;; --master) MASTER="$2"; shift 2;;
--token) TOKEN="$2"; shift 2;; --token) TOKEN="$2"; shift 2;;
--name) NAME="$2"; shift 2;; --name) NAME="$2"; shift 2;;
--probe-only) PROBE_ONLY=1; shift;;
*) error "未知参数: $1";; *) error "未知参数: $1";;
esac esac
done 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 "$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 "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); fi
if [[ -z "$NAME" && -n "$f_name" ]]; then NAME="$f_name"; INHERITED+=("节点名"); 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 if [[ ${#INHERITED[@]} -gt 0 ]]; then
info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)" info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)"
elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then
@@ -76,7 +81,7 @@ esac
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。 # sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它 # 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
# 重装节点(节点升级走面板一键更新,不需要本脚本)。 # 重装节点(节点升级走面板一键更新,不需要本脚本)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.1" RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}" XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}" ROOT="${XUI_ROOT:-}"
@@ -107,18 +112,30 @@ mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
SINGBOX_VER="1.13.21" SINGBOX_VER="1.13.21"
SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}" 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}}" SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}"
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
# 本机已有的核心文件不删(关掉纯探针后可直接复用)。
info "纯探针模式:跳过 sing-box 核心下载"
else
info "下载 sing-box v${SINGBOX_VER}..." 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" 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" [[ -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" 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}" 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" printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
fi
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、 # 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
# 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check; # 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
# agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入 # agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
# 写配置/起服务阶段。 # 写配置/起服务阶段。
self_check() { 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}" local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}" [[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
local sb_out local sb_out
@@ -137,11 +154,14 @@ self_check() {
self_check self_check
info "写入 agent 配置..." info "写入 agent 配置..."
PROBE_ONLY_JSON=""
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
"probeOnly": true'; fi
cat > "${CONF_DIR}/agent.json" <<EOF cat > "${CONF_DIR}/agent.json" <<EOF
{ {
"master_url": "$MASTER", "master_url": "$MASTER",
"token": "$TOKEN", "token": "$TOKEN",
"node_name": "$NAME" "node_name": "$NAME"${PROBE_ONLY_JSON}
} }
EOF EOF
chmod 600 "${CONF_DIR}/agent.json" chmod 600 "${CONF_DIR}/agent.json"
+67
View File
@@ -55,6 +55,11 @@ type Config struct {
// Empty: main derives it next to the agent config file; the agent // Empty: main derives it next to the agent config file; the agent
// falls back to the bin folder. // falls back to the bin folder.
BaselinePath string `json:"baseline_path,omitempty"` 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 // Agent represents the node agent
@@ -124,6 +129,11 @@ type Agent struct {
fastIntervalSec atomic.Int64 fastIntervalSec atomic.Int64
fastUnsupported atomic.Bool 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 // Latency probe runner (item 7 B4, see ping.go): task set pushed
// on the config poll, samples returned on the heartbeat. // on the config poll, samples returned on the heartbeat.
probes *probeRunner probes *probeRunner
@@ -505,6 +515,10 @@ func (a *Agent) fetchAndApplyConfig() error {
// report, automatic interface selection). // report, automatic interface selection).
ProbeFastInterval *int64 `json:"probe_fast_interval"` ProbeFastInterval *int64 `json:"probe_fast_interval"`
ProbeStatIface *string `json:"probe_stat_iface"` 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 { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err return err
@@ -523,6 +537,9 @@ func (a *Agent) fetchAndApplyConfig() error {
if result.ProbeStatIface != nil { if result.ProbeStatIface != nil {
SetStatIfacePin(*result.ProbeStatIface) SetStatIfacePin(*result.ProbeStatIface)
} }
if result.ProbeOnly != nil {
a.masterProbeOnly.Store(*result.ProbeOnly)
}
// One-click update intents ride this poll (item 20); handle them even // One-click update intents ride this poll (item 20); handle them even
// when the config itself is unchanged. // when the config itself is unchanged.
@@ -534,6 +551,16 @@ func (a *Agent) fetchAndApplyConfig() error {
a.probes.applyTasks(*result.ProbeTasks) 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 // Switchover (ITEM6 B5): a master that serves a sing-box config
// has made sing-box the node's formal runtime. Apply the config // has made sing-box the node's formal runtime. Apply the config
// through the switch sequence (which retires the legacy xray // through the switch sequence (which retires the legacy xray
@@ -598,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
return nil 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 // singboxRunning reports whether the managed sing-box process is
// currently alive. // currently alive.
func (a *Agent) singboxRunning() bool { 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 // (xray and, since ITEM6 B3, sing-box). The two cores keep separate
// clients and baselines; a failure in one never blocks the other. // clients and baselines; a failure in one never blocks the other.
func (a *Agent) collectAndReportTraffic() error { 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 var firstErr error
if err := a.collectAndReportXrayTraffic(); err != nil { if err := a.collectAndReportXrayTraffic(); err != nil {
firstErr = err firstErr = err
+41 -786
View File
@@ -1,34 +1,34 @@
package agent package agent
// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go: // probe.go is the platform-neutral core of the probe collector, ported
// host Facts (once per process) and per-heartbeat Metrics read directly from // from monitor-probe's agent collector (collect.rs, MIT): host Facts
// /proc and /sys with the standard library only. The port keeps the source's // (once per process) and per-heartbeat Metrics with the source's
// accounting discipline: // accounting discipline:
// - memory "used" is free(1)'s column: MemTotal - MemAvailable; // - memory "used" is free(1)'s column: total - available;
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree); // - disk "used" is df(1)'s: real storage only, total - free blocks
// - network totals sum only "counted" interfaces — loopback, container, // (not the unprivileged-available figure);
// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer // - network totals sum only "counted" interfaces — loopback,
// filter so no byte is counted twice; // container, tunnel and stacked devices are excluded by a filter so
// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set, // no byte is counted twice;
// so a set change re-baselines exactly like a reboot; // - boot epoch = a stable boot identity + FNV-1a fingerprint of the
// - every source degrades independently: an unreadable file yields a zero // counted set, so a set change re-baselines exactly like a reboot;
// field, never a failed collection round. // - 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 ( import (
"hash/fnv" "hash/fnv"
"net" "net"
"os"
"path/filepath"
"runtime"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
) )
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled // 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 // 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 { type probeCollector struct {
mu sync.Mutex mu sync.Mutex
prevCPU *cpuSample prevCPU *cpuSample
@@ -80,6 +80,9 @@ type probeCollector struct {
prevNetT time.Time 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 } type cpuSample struct{ total, idle uint64 }
var ( var (
@@ -94,37 +97,15 @@ func collectFacts() *ProbeFacts {
return factsCache 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 // statIfacePin (V2.0.1) names the single interface to count when
// the operator pinned one on the master (its probe_meta.stat_iface, // the operator pinned one on the master (its probe_meta.stat_iface,
// delivered on the config poll). "" keeps the automatic counted-set // delivered on the config poll). "" keeps the automatic counted-set
// selection below. The pin bypasses the three-layer filter on // selection. The pin bypasses the filter on purpose: the operator
// purpose: the operator named this exact device, including when it // named this exact device, including when it is one the filter would
// is one the filter would skip (a tunnel the node actually bills // skip (a tunnel the node actually bills on). A pinned name absent
// on). A pinned name absent from /proc/net/dev counts nothing — // from the interface table counts nothing — the boot epoch changes
// the boot epoch changes with the counted set, so the master // with the counted set, so the master re-baselines instead of booking
// re-baselines instead of booking a phantom delta. // a phantom delta.
var statIfacePin atomic.Value // string var statIfacePin atomic.Value // string
// SetStatIfacePin records the master's counted-interface pin. // SetStatIfacePin records the master's counted-interface pin.
@@ -137,6 +118,12 @@ func statIfacePinValue() string {
return "" return ""
} }
// netCounters is one interface's cumulative byte totals.
type netCounters struct {
name string
rx, tx uint64
}
func pinnedInterfaces(all []netCounters, pin string) []netCounters { func pinnedInterfaces(all []netCounters, pin string) []netCounters {
for _, c := range all { for _, c := range all {
if c.name == pin { if c.name == pin {
@@ -146,151 +133,9 @@ func pinnedInterfaces(all []netCounters, pin string) []netCounters {
return nil return nil
} }
// collectMetrics takes one full sample. Individual sources fail soft. // cpuPercent diffs two cumulative samples into a busy percentage. A
func collectMetrics() *ProbeMetrics { // missing or non-advancing previous sample reports 0 (the round only
probeColl.mu.Lock() // re-baselines) — the source's discipline on both platforms.
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 (c *probeCollector) cpuPercent(now *cpuSample) float32 { func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
if now == nil { if now == nil {
return 0 return 0
@@ -315,344 +160,14 @@ func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
return pct return pct
} }
func parseLoadavg(text string) [3]float32 { // bootEpoch = "{boot identity}/{FNV-1a-64 of the sorted counted names}".
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}".
// The master only tests it for equality: any change re-baselines. // 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...) sorted := append([]string(nil), names...)
sort.Strings(sorted) sort.Strings(sorted)
h := fnv.New64a() h := fnv.New64a()
_, _ = h.Write([]byte(strings.Join(sorted, "\n"))) _, _ = h.Write([]byte(strings.Join(sorted, "\n")))
return kernelBootID + "/" + hex16(h.Sum64()) return bootID + "/" + hex16(h.Sum64())
} }
func hex16(v uint64) string { 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) 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 // 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; // 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
// v6 is only 2000::/3. // v6 is only 2000::/3.
@@ -853,133 +238,3 @@ func isPublicIP(ip net.IP) bool {
} }
return ip16[0]&0xe0 == 0x20 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
}
+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 package agent
import ( import (
+463
View File
@@ -0,0 +1,463 @@
//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.
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.
var (
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
)
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,
}
}
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
memTotal, memUsed, swapTotal, swapUsed := winMemory()
diskTotal, diskUsed := winDiskUsage()
rows := 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: uint64(windows.DurationSinceBoot().Seconds()),
CPU: probeColl.cpuPercent(winCPUSample()),
Load: [3]float32{},
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
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: 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 {
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) {
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 {
var table *windows.MibIfTable2
if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || 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()) 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) { func (a *Agent) setUpdateReport(st agentUpdateStatus) {
a.updateMu.Lock() a.updateMu.Lock()
cp := st cp := st
@@ -82,6 +91,19 @@ func (a *Agent) maybeHandleUpdate(intent *updateIntent) {
a.updateMu.Unlock() a.updateMu.Unlock()
return 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() a.updateMu.Lock()
if a.updateRunning || intent.RequestedAt == a.updateHandled || if a.updateRunning || intent.RequestedAt == a.updateHandled ||
(intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) { (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{} var errExecBoom = &execBoomError{}
type execBoomError struct{} type execBoomError struct{}
+16 -2
View File
@@ -125,16 +125,30 @@ func GetDisplayVersion() string {
return defaultDisplayVersion 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"), // GetMasterDisplayVersion is the master's display version ("V1.0.0"),
// shown in the panel's version slot. // shown in the panel's version slot.
func GetMasterDisplayVersion() string { func GetMasterDisplayVersion() string {
return "V" + GetDisplayVersion() return "V" + bareDisplayVersion()
} }
// GetAgentDisplayVersion is the agent's display version ("A1.0.0"), // GetAgentDisplayVersion is the agent's display version ("A1.0.0"),
// reported in agent telemetry and shown on agent node rows. // reported in agent telemetry and shown on agent node rows.
func GetAgentDisplayVersion() string { func GetAgentDisplayVersion() string {
return "A" + GetDisplayVersion() return "A" + bareDisplayVersion()
} }
// ParseDisplayVersion parses a display version into its three numeric // 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" { if got := GetAgentDisplayVersion(); got != "A1.0.1" {
t.Fatalf("injected agent display version = %q, want A1.0.1", got) 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) { func TestParseDisplayVersion(t *testing.T) {
+7
View File
@@ -108,6 +108,13 @@ type AgentNode struct {
SeenIPv6 string `json:"seenIpv6"` SeenIPv6 string `json:"seenIpv6"`
SeenIPAt int64 `json:"seenIpAt"` 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; // One-click update (item 20): the master records an update intent here;
// the agent picks it up from its config poll, downloads + verifies + // the agent picks it up from its config poll, downloads + verifies +
// swaps its binary, restarts itself and reports the outcome back on // 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": case "tls":
applyShareTLSParams(stream, params) applyShareTLSParams(stream, params)
case "reality": case "reality":
applyShareRealityParams(stream, params) applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
default: default:
params["security"] = "none" params["security"] = "none"
} }
@@ -1010,7 +1010,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
case "tls": case "tls":
applyShareTLSParams(stream, params) applyShareTLSParams(stream, params)
case "reality": case "reality":
applyShareRealityParams(stream, params) applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow { if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow {
params["flow"] = client.Flow params["flow"] = client.Flow
} }
@@ -1700,7 +1700,30 @@ func hysteriaPinHex(pin string) string {
return pin 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" params["security"] = "reality"
realitySetting, _ := stream["realitySettings"].(map[string]any) realitySetting, _ := stream["realitySettings"].(map[string]any)
realitySettings, _ := searchKey(realitySetting, "settings") 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) 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 { if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok {
params["pbk"], _ = pbkValue.(string) 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 // realityTwoClientInbound builds a reality VLESS inbound carrying two clients
// with distinct subIds. // with distinct subIds.
func realityTwoClientInbound() *model.Inbound { func realityTwoClientInbound() *model.Inbound {
+18 -3
View File
@@ -671,22 +671,37 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"}) c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return 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) sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
if sbErr != nil { if sbErr != nil {
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr) 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"}) c.JSON(500, gin.H{"message": "failed to build sing-box config"})
return return
} }
sbRaw, mErr := json.Marshal(sbCfg) raw, mErr := json.Marshal(sbCfg)
if mErr != nil { if mErr != nil {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr) 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"}) c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return return
} }
sbRaw = raw
sbSum := sha256.Sum256(sbRaw) sbSum := sha256.Sum256(sbRaw)
sbHash = hex.EncodeToString(sbSum[:])
}
payload := gin.H{ payload := gin.H{
"singbox_config": json.RawMessage(sbRaw), "singbox_config": sbRaw,
"singbox_config_hash": hex.EncodeToString(sbSum[:]), "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 // Item 7 B4: the agent's probe task set rides the existing
// config poll (no new channel). Always present from this // config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while // master version — an empty list means "run no probes", while
@@ -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" "strconv"
"time" "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/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -167,11 +170,25 @@ func (a *NodeController) add(c *gin.Context) {
if isAgent { if isAgent {
view.NodeType = "agent" view.NodeType = "agent"
nodeID := view.Id nodeID := view.Id
token, _, err := issueAgentToken(&nodeID, n.Name) token, agentID, err := issueAgentToken(&nodeID, n.Name)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return 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{ jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
"node": view, "node": view,
"agentToken": token, "agentToken": token,
+11
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 { if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err 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) return s.MarkNodeDirtyTx(tx, id)
}); err != nil { }); err != nil {
return err return err
@@ -7,6 +7,28 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "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 // P13: annotateAgentTelemetry overlays the agent-reported heartbeat
// telemetry onto agent node views only — panel version becomes // telemetry onto agent node views only — panel version becomes
// "agent <version>", xray version/uptime/latency/CPU/mem/IPs take the // "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. // traditional nodes and for agents with no known address yet.
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"` Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::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: // Agent one-click update bookkeeping (item 20), agent nodes only:
// the last intent's state/target and, on failure, its error. // the last intent's state/target and, on failure, its error.
AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"` 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 // annotateAgentTelemetry overlays the latest heartbeat telemetry reported
// by each node's bound agent onto its view (P13): xray version, the // by each node's bound agent onto its view (P13): xray version, the
// "agent <version>" panel-version label, host uptime, node->master latency, // "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 // its first telemetry report: an agent that has only registered
// already has a master-observed source address. // already has a master-observed source address.
v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag) 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 { if ag.TelemetryAt == 0 {
continue continue
} }
@@ -235,14 +268,7 @@ func annotateAgentTelemetry(views []*NodeView) {
v.SingboxVersion = ag.SingboxVersion v.SingboxVersion = ag.SingboxVersion
} }
if ag.AgentVersion != "" { if ag.AgentVersion != "" {
// New agents report the A-series display version, shown v.PanelVersion = agentVersionCell(ag.AgentVersion)
// 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.AgentUpdateState = ag.UpdateState v.AgentUpdateState = ag.UpdateState
v.AgentUpdateError = ag.UpdateError v.AgentUpdateError = ag.UpdateError
v.AgentUpdateTarget = ag.UpdateTarget v.AgentUpdateTarget = ag.UpdateTarget
@@ -278,6 +304,11 @@ type NodeMutationRequest struct {
// agent nodes. Agent nodes skip the panel reachability probe and the API // agent nodes. Agent nodes skip the panel reachability probe and the API
// token requirement; an agent token is issued on creation instead. // token requirement; an agent token is issued on creation instead.
NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"` 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 { 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")
}
}
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "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": "传统面板节点", "nodeTypeTraditional": "传统面板节点",
"nodeTypeAgent": "Agent 轻量节点", "nodeTypeAgent": "Agent 轻量节点",
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。", "agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
"probeOnly": "纯探针",
"probeOnlyHint": "纯探针模式:该节点不运行任何代理核心,只保留探针功能(心跳、指标与延迟探测)。",
"agentTokenTitle": "Agent 节点安装", "agentTokenTitle": "Agent 节点安装",
"agentTokenCreated": "节点 {{name}} 创建成功", "agentTokenCreated": "节点 {{name}} 创建成功",
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!", "agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight 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.", "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", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",