Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a25c51003e | ||
|
|
b27bcfcb5b | ||
|
|
75f10b27fa | ||
|
|
7df3a338ef | ||
|
|
5ca22ec340 | ||
|
|
2d37a6de18 |
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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."
|
||||||
+30
-10
@@ -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}}"
|
||||||
info "下载 sing-box v${SINGBOX_VER}..."
|
if [[ "$PROBE_ONLY" == "1" ]]; then
|
||||||
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
|
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
|
||||||
[[ -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"
|
info "纯探针模式:跳过 sing-box 核心下载"
|
||||||
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
|
else
|
||||||
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
|
info "下载 sing-box v${SINGBOX_VER}..."
|
||||||
|
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
|
||||||
|
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
|
||||||
|
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
|
||||||
|
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
|
||||||
|
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
|
||||||
|
fi
|
||||||
|
|
||||||
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
|
# 安装收尾自检(第 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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
|
// Probe-only nodes (V2.1.0) get no core payload at all: the build
|
||||||
if sbErr != nil {
|
// is skipped entirely, so a broken data model on such a node can
|
||||||
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
|
// never 500 its poll, and the probe keys + update intent below
|
||||||
c.JSON(500, gin.H{"message": "failed to build sing-box config"})
|
// ride along exactly as for normal nodes. singbox_config stays
|
||||||
return
|
// null and the hash empty, which agents read as "no payload".
|
||||||
|
var sbRaw json.RawMessage
|
||||||
|
sbHash := ""
|
||||||
|
if !agent.ProbeOnly {
|
||||||
|
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
|
||||||
|
if sbErr != nil {
|
||||||
|
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
|
||||||
|
c.JSON(500, gin.H{"message": "failed to build sing-box config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, mErr := json.Marshal(sbCfg)
|
||||||
|
if mErr != nil {
|
||||||
|
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
|
||||||
|
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sbRaw = raw
|
||||||
|
sbSum := sha256.Sum256(sbRaw)
|
||||||
|
sbHash = hex.EncodeToString(sbSum[:])
|
||||||
}
|
}
|
||||||
sbRaw, mErr := json.Marshal(sbCfg)
|
|
||||||
if mErr != nil {
|
|
||||||
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
|
|
||||||
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sbSum := sha256.Sum256(sbRaw)
|
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
@@ -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 只显示一次!",
|
||||||
|
|||||||
@@ -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!",
|
||||||
|
|||||||
Reference in New Issue
Block a user