Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81eff8c0d | ||
|
|
1d7eb16784 | ||
|
|
a13179ed9b | ||
|
|
93c1620c15 | ||
|
|
ba57fb3c00 | ||
|
|
151b07b73f | ||
|
|
cec5905483 | ||
|
|
1df78dd874 | ||
|
|
92dfdc542c | ||
|
|
a25c51003e | ||
|
|
b27bcfcb5b | ||
|
|
75f10b27fa | ||
|
|
7df3a338ef | ||
|
|
5ca22ec340 | ||
|
|
2d37a6de18 | ||
|
|
db6f666ff7 | ||
|
|
d4d259fa58 | ||
|
|
9caa4e0cf9 | ||
|
|
29de9761c8 |
@@ -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
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MoonOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
RadarChartOutlined,
|
||||
ReadOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
@@ -249,6 +250,11 @@ export default function AppSidebar() {
|
||||
icon: <GlobalOutlined />,
|
||||
label: t('pages.settings.probeThemes.title'),
|
||||
},
|
||||
{
|
||||
key: '/settings#probe',
|
||||
icon: <RadarChartOutlined />,
|
||||
label: t('pages.settings.probe.title'),
|
||||
},
|
||||
];
|
||||
return children;
|
||||
}, [t]);
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
import { getHeaderValue } from './headers';
|
||||
import { canEnableTlsFlow } from './protocol-capabilities';
|
||||
import { deriveSpiderX } from './spider-x';
|
||||
|
||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||
// client overrides and returns a URL (or '' when the protocol doesn't
|
||||
@@ -353,7 +352,6 @@ export interface GenVlessLinkInput {
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientId: string;
|
||||
clientKey?: string;
|
||||
flow?: VlessClient['flow'];
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
@@ -374,7 +372,7 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
|
||||
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
||||
// query carries network type, encryption, network-specific knobs, and
|
||||
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
||||
// pbk/sid/spx). Returns '' if the inbound isn't vless.
|
||||
// pbk/sid). Returns '' if the inbound isn't vless.
|
||||
export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
const {
|
||||
inbound,
|
||||
@@ -383,7 +381,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientId,
|
||||
clientKey = '',
|
||||
flow = '',
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
@@ -462,8 +459,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
||||
if (spx.length > 0) params.set('spx', spx);
|
||||
if (reality.settings.mldsa65Verify.length > 0)
|
||||
params.set('pqv', reality.settings.mldsa65Verify);
|
||||
}
|
||||
@@ -560,7 +555,6 @@ function writeTlsParams(
|
||||
function writeRealityParams(
|
||||
stream: NonNullable<Inbound['streamSettings']>,
|
||||
params: URLSearchParams,
|
||||
clientKey: string,
|
||||
): void {
|
||||
if (stream.security !== 'reality') return;
|
||||
const reality = stream.realitySettings;
|
||||
@@ -573,8 +567,6 @@ function writeRealityParams(
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
||||
if (spx.length > 0) params.set('spx', spx);
|
||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||
}
|
||||
|
||||
@@ -585,7 +577,6 @@ export interface GenTrojanLinkInput {
|
||||
forceTls?: ForceTls;
|
||||
remark?: string;
|
||||
clientPassword: string;
|
||||
clientKey?: string;
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
@@ -600,7 +591,6 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
clientPassword,
|
||||
clientKey = '',
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
|
||||
@@ -621,7 +611,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
} else if (security === 'reality') {
|
||||
params.set('security', 'reality');
|
||||
writeRealityParams(stream, params, clientKey);
|
||||
writeRealityParams(stream, params);
|
||||
} else {
|
||||
params.set('security', 'none');
|
||||
}
|
||||
@@ -1321,12 +1311,6 @@ type ClientShape = {
|
||||
subId?: string;
|
||||
};
|
||||
|
||||
// Mirror of the Go subKey: the stable per-client identity spx derivation
|
||||
// keys on — subscription id first, unique email as the fallback.
|
||||
function clientSubKey(client: ClientShape): string {
|
||||
return client.subId || client.email || '';
|
||||
}
|
||||
|
||||
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
||||
switch (inbound.protocol) {
|
||||
case 'vmess':
|
||||
@@ -1392,7 +1376,6 @@ export function genLink(input: GenLinkInput): string {
|
||||
forceTls,
|
||||
remark,
|
||||
clientId: client.id ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
flow: client.flow,
|
||||
externalProxy,
|
||||
});
|
||||
@@ -1416,7 +1399,6 @@ export function genLink(input: GenLinkInput): string {
|
||||
forceTls,
|
||||
remark,
|
||||
clientPassword: client.password ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
externalProxy,
|
||||
});
|
||||
case 'hysteria':
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||
|
||||
// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
|
||||
// links and subscription links agree; returns '' when there is no seed and
|
||||
// no client key (the caller then omits spx, as the legacy builder did).
|
||||
export function deriveSpiderX(seed: string, clientKey: string): string {
|
||||
if (!seed && !clientKey) return '';
|
||||
return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -21,7 +22,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||
import ProbeMetaSection from './ProbeMetaSection';
|
||||
|
||||
import './NodeFormModal.css';
|
||||
|
||||
type Mode = 'add' | 'edit';
|
||||
@@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues {
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
hasStoredToken: false,
|
||||
probeOnly: false,
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
tlsVerifyMode: 'verify',
|
||||
@@ -70,6 +72,7 @@ export default function NodeFormModal({
|
||||
onOpenChange,
|
||||
}: NodeFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
@@ -81,6 +84,9 @@ export default function NodeFormModal({
|
||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
// Whether the just-created agent node was flagged probe-only, so the
|
||||
// install command below can carry the matching --probe-only flag.
|
||||
const [agentProbeOnly, setAgentProbeOnly] = useState(false);
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
// Edit mode (item 19): the stored install material for an existing agent
|
||||
// node, fetched from the master; null until loaded / for non-agent nodes.
|
||||
@@ -98,6 +104,7 @@ export default function NodeFormModal({
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
||||
const isAgent = nodeType === 'agent';
|
||||
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
|
||||
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||
|
||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||
@@ -176,10 +183,13 @@ export default function NodeFormModal({
|
||||
const base =
|
||||
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
|
||||
` --master ${editMasterUrl}`;
|
||||
return shownInstall.hasToken
|
||||
const cmd = shownInstall.hasToken
|
||||
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
||||
: `${base} --name ${shownInstall.name}`;
|
||||
}, [shownInstall, editMasterUrl]);
|
||||
// The toggle is the source of truth for the re-displayed command:
|
||||
// flipping it rewrites the command the operator would re-run.
|
||||
return probeOnly ? `${cmd} --probe-only` : cmd;
|
||||
}, [shownInstall, editMasterUrl, probeOnly]);
|
||||
|
||||
const title = useMemo(
|
||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||
@@ -209,6 +219,9 @@ export default function NodeFormModal({
|
||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||
outboundTag: values.outboundTag || '',
|
||||
};
|
||||
// Probe-only (V2.1.0) is an agent-node concept; traditional
|
||||
// payloads stay byte-identical to before.
|
||||
if (agentMode) payload.probeOnly = values.probeOnly ?? false;
|
||||
if (token) payload.apiToken = token;
|
||||
return payload;
|
||||
}
|
||||
@@ -294,6 +307,7 @@ export default function NodeFormModal({
|
||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
||||
setAgentToken(obj.agentToken);
|
||||
setAgentNodeName(result.data.name.trim());
|
||||
setAgentProbeOnly(result.data.probeOnly ?? false);
|
||||
// Default the master URL to the address this panel is being
|
||||
// viewed through — origin AND base path. Dropping the base
|
||||
// path made the agent's registration 404. User can edit it.
|
||||
@@ -316,7 +330,7 @@ export default function NodeFormModal({
|
||||
// one would produce a command that can never register, so withhold it
|
||||
// (and say why) instead of letting the user copy a dead command.
|
||||
const installCmd = agentMasterUrl
|
||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
|
||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}${agentProbeOnly ? ' --probe-only' : ''}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
@@ -428,11 +442,21 @@ export default function NodeFormModal({
|
||||
)}
|
||||
{isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.probeOnly')}
|
||||
name="probeOnly"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.probeOnlyHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
@@ -474,7 +498,15 @@ export default function NodeFormModal({
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && node?.id ? (
|
||||
<ProbeMetaSection nodeId={node.id} />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.nodes.probe.title')}
|
||||
</div>
|
||||
<p style={{ marginBottom: 8 }}>{t('pages.nodes.probe.movedHint')}</p>
|
||||
<Button onClick={() => navigate('/settings#probe')}>
|
||||
{t('pages.nodes.probe.goSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAgent && (
|
||||
|
||||
@@ -307,13 +307,13 @@ export default function NodeList({
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.probe')}>
|
||||
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.probe')}
|
||||
aria-label={t('pages.nodes.testConnection')}
|
||||
onClick={() => onProbe(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -493,10 +493,23 @@ export default function NodeList({
|
||||
render: (_value, record) => formatPct(record.memPct),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.xrayVersion'),
|
||||
// Core version (V2.0.1 ②): agent rows run sing-box and show
|
||||
// its version; traditional rows keep showing the xray one.
|
||||
// Probe-only agent rows (V2.1.0) run no core by design: the
|
||||
// cell says so instead of a bare '-', which reads as a fault.
|
||||
title: t('pages.nodes.coreVersion'),
|
||||
dataIndex: 'xrayVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => record.xrayVersion || '-',
|
||||
render: (_value, record) =>
|
||||
record.nodeType === 'agent'
|
||||
? record.probeOnly
|
||||
? (
|
||||
<Tag color="cyan" style={{ margin: 0 }}>
|
||||
{t('pages.nodes.probeOnly')}
|
||||
</Tag>
|
||||
)
|
||||
: record.singboxVersion || record.xrayVersion || '-'
|
||||
: record.xrayVersion || '-',
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||
@@ -703,7 +716,7 @@ export default function NodeList({
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.probe')}
|
||||
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
@@ -840,8 +853,12 @@ export default function NodeList({
|
||||
<Tag>{formatPct(statsNode.memPct)}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
|
||||
<Tag>{statsNode.xrayVersion || '-'}</Tag>
|
||||
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
|
||||
<Tag>
|
||||
{statsNode.nodeType === 'agent'
|
||||
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
|
||||
: statsNode.xrayVersion || '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Alert,
|
||||
@@ -33,7 +34,6 @@ import { useNodeMutations } from '@/api/queries/useNodeMutations';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import NodeList from './NodeList';
|
||||
import NodeFormModal from './NodeFormModal';
|
||||
import ProbeTasksModal from './ProbeTasksModal';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
|
||||
@@ -70,6 +70,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
@@ -105,7 +106,6 @@ export default function NodesPage() {
|
||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [mtlsOpen, setMtlsOpen] = useState(false);
|
||||
const [probeTasksOpen, setProbeTasksOpen] = useState(false);
|
||||
const [trustCa, setTrustCa] = useState('');
|
||||
const [copyingCa, setCopyingCa] = useState(false);
|
||||
const [savingTrustCa, setSavingTrustCa] = useState(false);
|
||||
@@ -369,7 +369,7 @@ export default function NodesPage() {
|
||||
onSelectionChange={setSelectedIds}
|
||||
onAdd={onAdd}
|
||||
onMtls={() => setMtlsOpen(true)}
|
||||
onProbeTasks={() => setProbeTasksOpen(true)}
|
||||
onProbeTasks={() => navigate('/settings#probe')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onProbe={onProbe}
|
||||
@@ -384,12 +384,6 @@ export default function NodesPage() {
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<ProbeTasksModal
|
||||
open={probeTasksOpen}
|
||||
onClose={() => setProbeTasksOpen(false)}
|
||||
nodes={nodes}
|
||||
/>
|
||||
|
||||
<NodeFormModal
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
|
||||
@@ -23,8 +23,23 @@ interface ProbeMetaWire {
|
||||
countryPin: string;
|
||||
ipv4Pin: string;
|
||||
ipv6Pin: string;
|
||||
// V2.0.1: ledger corrections in GB (either sign) and the counted
|
||||
// interface pin ('' = automatic selection on the agent). Since
|
||||
// V2.2.1 the corrections are one-shot deltas: the inputs below are
|
||||
// local state (never prefilled from the wire), a save submits them
|
||||
// once, and they clear back to empty on success.
|
||||
trafficCalUpGb: number;
|
||||
trafficCalDownGb: number;
|
||||
statIface: string;
|
||||
}
|
||||
|
||||
// GB entry converts on the binary convention every byte formatter in
|
||||
// the panel and on the public page uses (V2.2.1: was decimal 1e9,
|
||||
// which made a 3000 GB limit render as 2.73 TB instead of 2.93 TB).
|
||||
const BYTES_PER_GB = 1024 ** 3;
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
const NAMED_CYCLES = [
|
||||
'once',
|
||||
'monthly',
|
||||
@@ -52,6 +67,9 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
// in NodeFormModal); visibility derives during render.
|
||||
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
|
||||
const [trafficGb, setTrafficGb] = useState(0);
|
||||
// One-shot calibration deltas (GB, either sign): null = empty box.
|
||||
const [calUpGb, setCalUpGb] = useState<number | null>(null);
|
||||
const [calDownGb, setCalDownGb] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,7 +79,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
}).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setLoaded({ nodeId, meta: msg.obj });
|
||||
setTrafficGb(msg.obj.trafficLimit / 1e9);
|
||||
setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -109,13 +127,23 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
try {
|
||||
const payload: ProbeMetaWire = {
|
||||
...meta,
|
||||
trafficLimit: Math.round(trafficGb * 1e9),
|
||||
trafficLimit: Math.round(trafficGb * BYTES_PER_GB),
|
||||
trafficCalUpGb: calUpGb ?? 0,
|
||||
trafficCalDownGb: calDownGb ?? 0,
|
||||
};
|
||||
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
if (msg?.success) {
|
||||
setLoaded({ nodeId, meta: payload });
|
||||
// One-shot: the submitted corrections are folded in server-
|
||||
// side; the boxes clear so a later save resubmits nothing.
|
||||
setLoaded({
|
||||
nodeId,
|
||||
meta: { ...payload, trafficCalUpGb: 0, trafficCalDownGb: 0 },
|
||||
});
|
||||
setCalUpGb(null);
|
||||
setCalDownGb(null);
|
||||
messageApi.success(t('pages.nodes.probe.saved'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
|
||||
@@ -237,6 +265,40 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
</Field>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.calUp')}>
|
||||
<InputNumber
|
||||
value={calUpGb}
|
||||
step={0.1}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalUpGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.calDown')}>
|
||||
<InputNumber
|
||||
value={calDownGb}
|
||||
step={0.1}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalDownGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.statIface')}>
|
||||
<Input
|
||||
value={meta.statIface}
|
||||
placeholder="eth0"
|
||||
onChange={(e) => set('statIface', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</Col>
|
||||
</Row>
|
||||
<p style={{ marginTop: -4, marginBottom: 12 }}>{t('pages.nodes.probe.calHint')}</p>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.countryPin')}>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Col, Input, InputNumber, Row, Select, Space, Switch, Typography, message } from 'antd';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import ProbeMetaSection from '../nodes/ProbeMetaSection';
|
||||
import ProbeTasksPanel from './ProbeTasksPanel';
|
||||
|
||||
// V2.0.1 probe batch: the dedicated probe settings page. Everything
|
||||
// probe-related that used to be scattered — the public-page block
|
||||
// (public-page tab), the latency task manager (nodes-page modal)
|
||||
// and the per-node probe metadata (node edit dialog) — is gathered
|
||||
// here and lives nowhere else. Like the surfaces it replaces, each
|
||||
// block loads and saves through its own endpoints and takes effect
|
||||
// immediately (no panel save, no restart).
|
||||
interface ProbeSettingsWire {
|
||||
publicPage: boolean;
|
||||
siteName: string;
|
||||
site: string;
|
||||
githubProxy: string;
|
||||
retentionDays: number;
|
||||
fastIntervalSec: number;
|
||||
favicon: string;
|
||||
touchIcon: string;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
const MAX_ICON_BYTES = 20 * 1024;
|
||||
|
||||
function readAsDataURL(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
|
||||
// icon budget (moved unchanged from the public-page tab): small
|
||||
// files pass through; a large raster is scaled down until it fits;
|
||||
// a large SVG cannot be scaled and is refused here.
|
||||
async function fileToIconDataURL(file: File): Promise<string> {
|
||||
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
|
||||
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
|
||||
throw new Error('tooLarge');
|
||||
}
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
for (const size of [256, 180, 128, 96, 64]) {
|
||||
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) break;
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
|
||||
}
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
throw new Error('tooLarge');
|
||||
}
|
||||
|
||||
export default function ProbeSettingsTab() {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [settings, setSettings] = useState<ProbeSettingsWire | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [metaNodeId, setMetaNodeId] = useState<number | undefined>(undefined);
|
||||
const faviconRef = useRef<HTMLInputElement>(null);
|
||||
const touchRef = useRef<HTMLInputElement>(null);
|
||||
const { nodes } = useNodesQuery();
|
||||
const agentNodes = nodes.filter((n) => n.nodeType === 'agent');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
HttpUtil.get<ProbeSettingsWire>('/panel/api/probe/settings', { silent: true }).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) setSettings(msg.obj);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function saveSettings() {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.put('/panel/api/probe/settings', settings, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
|
||||
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
|
||||
if (!file || !settings) return;
|
||||
try {
|
||||
const url = await fileToIconDataURL(file);
|
||||
setSettings({ ...settings, [which]: url });
|
||||
} catch {
|
||||
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
|
||||
}
|
||||
}
|
||||
|
||||
const set = <K extends keyof ProbeSettingsWire>(key: K, value: ProbeSettingsWire[K]) =>
|
||||
setSettings((s) => (s ? { ...s, [key]: value } : s));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messageContextHolder}
|
||||
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
|
||||
{settings ? (
|
||||
<>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
|
||||
<Switch
|
||||
checked={settings.publicPage}
|
||||
onChange={(v) => set('publicPage', v)}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{t('pages.settings.probeThemes.publicPageHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
|
||||
<Input
|
||||
value={settings.siteName}
|
||||
onChange={(e) => set('siteName', e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
|
||||
<Input
|
||||
value={settings.site}
|
||||
placeholder="https://"
|
||||
onChange={(e) => set('site', e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
|
||||
<Input
|
||||
value={settings.githubProxy}
|
||||
placeholder="https://"
|
||||
onChange={(e) => set('githubProxy', e.target.value)}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.githubProxyHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.retentionDays')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.retentionDays}
|
||||
onChange={(v) => set('retentionDays', v ?? 7)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probe.retentionHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.fastInterval')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={60}
|
||||
value={settings.fastIntervalSec}
|
||||
onChange={(v) => set('fastIntervalSec', v ?? 3)}
|
||||
addonAfter="s"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probe.fastIntervalHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
|
||||
<Space>
|
||||
{settings.favicon ? (
|
||||
<img src={settings.favicon} alt="" style={{ width: 24, height: 24 }} />
|
||||
) : null}
|
||||
<Button size="small" onClick={() => faviconRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{settings.favicon ? (
|
||||
<Button size="small" onClick={() => set('favicon', '')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={faviconRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
|
||||
<Space>
|
||||
{settings.touchIcon ? (
|
||||
<img src={settings.touchIcon} alt="" style={{ width: 24, height: 24 }} />
|
||||
) : null}
|
||||
<Button size="small" onClick={() => touchRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{settings.touchIcon ? (
|
||||
<Button size="small" onClick={() => set('touchIcon', '')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={touchRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.iconHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button type="primary" loading={saving} onClick={saveSettings}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
{t('pages.nodes.probeTasks.title')}
|
||||
</Typography.Title>
|
||||
<ProbeTasksPanel nodes={nodes} />
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
{t('pages.settings.probe.metaTitle')}
|
||||
</Typography.Title>
|
||||
<Select
|
||||
placeholder={t('pages.settings.probe.metaNodePlaceholder')}
|
||||
value={metaNodeId}
|
||||
onChange={setMetaNodeId}
|
||||
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||
style={{ minWidth: 260, marginBottom: 16 }}
|
||||
/>
|
||||
{metaNodeId ? <ProbeMetaSection key={metaNodeId} nodeId={metaNodeId} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+136
-36
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
@@ -19,12 +18,14 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
|
||||
// Item 7 B4: probe (latency) task management. The operator maintains
|
||||
// the target set here — grouped by the free-form region label, no
|
||||
// preset list (user ruling Q4). Like ProbeMetaSection the surface is
|
||||
// self-contained: it loads and saves on its own through the dedicated
|
||||
// endpoints, effective immediately (the master pushes the set on each
|
||||
// agent's next config poll; nothing restarts).
|
||||
// Probe (latency) task management — the panel form of what used to
|
||||
// be the nodes-page modal (moved to the probe settings page in
|
||||
// V2.0.1; the target set lives in exactly one place now). The
|
||||
// operator maintains the target set here — grouped by the free-form
|
||||
// region label, no preset list (user ruling Q4). The surface is
|
||||
// self-contained: it loads and saves on its own through the
|
||||
// dedicated endpoints, effective immediately (the master pushes the
|
||||
// set on each agent's next config poll; nothing restarts).
|
||||
interface ProbeTaskWire {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -48,22 +49,38 @@ interface TaskDraft {
|
||||
}
|
||||
|
||||
const TASKS_URL = '/panel/api/agent/probe-tasks';
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
export default function ProbeTasksModal({
|
||||
open,
|
||||
onClose,
|
||||
nodes,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
nodes: NodeRecord[];
|
||||
}) {
|
||||
function parseTargets(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of text.split(/[\n,,]+/)) {
|
||||
const v = raw.trim();
|
||||
if (v && !seen.has(v)) {
|
||||
seen.add(v);
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [tasks, setTasks] = useState<ProbeTaskWire[] | null>(null);
|
||||
const [draft, setDraft] = useState<TaskDraft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Batch-add state (V2.0.1 ⑤): many targets pasted at once sharing
|
||||
// every other field.
|
||||
const [batchText, setBatchText] = useState('');
|
||||
const [batchName, setBatchName] = useState('');
|
||||
const [batchInterval, setBatchInterval] = useState(60);
|
||||
const [batchAutoJoin, setBatchAutoJoin] = useState(false);
|
||||
const [batchRegion, setBatchRegion] = useState('');
|
||||
const [batchNodeIds, setBatchNodeIds] = useState<number[]>([]);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
|
||||
const agentNodes = useMemo(() => nodes.filter((n) => n.nodeType === 'agent'), [nodes]);
|
||||
const nodeName = useCallback(
|
||||
(id: number) => nodes.find((n) => n.id === id)?.name ?? `#${id}`,
|
||||
@@ -79,15 +96,9 @@ export default function ProbeTasksModal({
|
||||
}
|
||||
}, [messageApi, t]);
|
||||
|
||||
// Load on open (afterOpenChange is an event callback, so resetting
|
||||
// the previous session's state here is not an effect write).
|
||||
const onAfterOpenChange = (opened: boolean) => {
|
||||
if (opened) {
|
||||
setTasks(null);
|
||||
setDraft(null);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
// Group by region: region labels ascending, the ungrouped bucket
|
||||
// last; inside a group the panel order (sort) rules.
|
||||
@@ -147,7 +158,10 @@ export default function ProbeTasksModal({
|
||||
nodeIds: draft.nodeIds,
|
||||
};
|
||||
if (draft.baseNodeIds) payload.baseNodeIds = draft.baseNodeIds;
|
||||
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, { silent: true });
|
||||
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.nodes.probeTasks.saved'));
|
||||
setDraft(null);
|
||||
@@ -160,6 +174,40 @@ export default function ProbeTasksModal({
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchAdd() {
|
||||
const targets = parseTargets(batchText);
|
||||
if (targets.length === 0) {
|
||||
messageApi.error(t('pages.nodes.probeTasks.batchEmpty'));
|
||||
return;
|
||||
}
|
||||
setBatchSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ ids: number[] }>(
|
||||
`${TASKS_URL}/batch`,
|
||||
{
|
||||
targets,
|
||||
name: batchName,
|
||||
interval: batchInterval,
|
||||
autoJoin: batchAutoJoin,
|
||||
region: batchRegion,
|
||||
nodeIds: batchNodeIds,
|
||||
},
|
||||
{ silent: true, ...JSON_HEADERS },
|
||||
);
|
||||
if (msg?.success) {
|
||||
messageApi.success(
|
||||
t('pages.nodes.probeTasks.batchDone', { count: msg.obj?.ids.length ?? targets.length }),
|
||||
);
|
||||
setBatchText('');
|
||||
await load();
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
|
||||
}
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(task: ProbeTaskWire) {
|
||||
const msg = await HttpUtil.delete(`${TASKS_URL}/${task.id}`, { silent: true });
|
||||
if (msg?.success) {
|
||||
@@ -226,20 +274,72 @@ export default function ProbeTasksModal({
|
||||
setDraft((d) => (d ? { ...d, [key]: value } : d));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.nodes.probeTasks.title')}
|
||||
onCancel={onClose}
|
||||
afterOpenChange={onAfterOpenChange}
|
||||
footer={null}
|
||||
width={780}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div>
|
||||
{messageContextHolder}
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
{t('pages.nodes.probeTasks.intro')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>{t('pages.nodes.probeTasks.batchTitle')}</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={batchText}
|
||||
placeholder={t('pages.nodes.probeTasks.batchPlaceholder')}
|
||||
onChange={(e) => setBatchText(e.target.value)}
|
||||
style={{ marginTop: 8, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.name')}
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.region')}
|
||||
value={batchRegion}
|
||||
onChange={(e) => setBatchRegion(e.target.value)}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={5}
|
||||
max={3600}
|
||||
value={batchInterval}
|
||||
onChange={(v) => setBatchInterval(v ?? 60)}
|
||||
addonAfter="s"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span>
|
||||
{t('pages.nodes.probeTasks.autoJoin')}{' '}
|
||||
<Switch checked={batchAutoJoin} onChange={setBatchAutoJoin} />
|
||||
</span>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
|
||||
value={batchNodeIds}
|
||||
onChange={setBatchNodeIds}
|
||||
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||
style={{ minWidth: 260 }}
|
||||
/>
|
||||
<Button type="primary" loading={batchSaving} onClick={() => void onBatchAdd()}>
|
||||
{t('pages.nodes.probeTasks.batchSubmit')}
|
||||
</Button>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.nodes.probeTasks.batchHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks === null ? (
|
||||
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.loading')}</Typography.Text>
|
||||
) : tasks.length === 0 && !draft ? (
|
||||
@@ -326,6 +426,6 @@ export default function ProbeTasksModal({
|
||||
{t('pages.nodes.probeTasks.add')}
|
||||
</Button>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,13 @@ import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/ico
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
// Item 7 B5: the public status page's settings + theme store. Like
|
||||
// the probe-meta section this surface is self-contained — it loads
|
||||
// and saves through its own endpoints and every change takes effect
|
||||
// immediately (no panel save, no restart). Theme packages are static
|
||||
// files served by the panel; installing one never runs its code here.
|
||||
// Item 7 B5: the public status page's theme store + notify block.
|
||||
// (The public-page settings themselves moved to the probe settings
|
||||
// page in V2.0.1.) Like the probe-meta section this surface is
|
||||
// self-contained — it loads and saves through its own endpoints and
|
||||
// every change takes effect immediately (no panel save, no
|
||||
// restart). Theme packages are static files served by the panel;
|
||||
// installing one never runs its code here.
|
||||
interface ThemeConfigField {
|
||||
key: string;
|
||||
type: string;
|
||||
@@ -47,18 +49,8 @@ interface ThemeWire {
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
interface ProbeSettingsWire {
|
||||
publicPage: boolean;
|
||||
siteName: string;
|
||||
site: string;
|
||||
githubProxy: string;
|
||||
}
|
||||
|
||||
interface ThemesPayload {
|
||||
themes: ThemeWire[];
|
||||
settings: ProbeSettingsWire;
|
||||
favicon: string;
|
||||
touchIcon: string;
|
||||
}
|
||||
|
||||
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
|
||||
@@ -78,83 +70,27 @@ interface NotifySettingsWire {
|
||||
notifyWebhookHeadersSet: boolean;
|
||||
}
|
||||
|
||||
interface CoverageRow {
|
||||
name: string;
|
||||
online: boolean;
|
||||
hasProbe: boolean;
|
||||
probeAt: number;
|
||||
}
|
||||
|
||||
interface NotifyPayload {
|
||||
settings: NotifySettingsWire;
|
||||
coverage: CoverageRow[];
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
const UPLOAD_CHUNK = 1 << 20;
|
||||
const MAX_ICON_BYTES = 20 * 1024;
|
||||
|
||||
function basePathPrefix(): string {
|
||||
const bp = window.X_UI_BASE_PATH;
|
||||
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
|
||||
}
|
||||
|
||||
function readAsDataURL(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
|
||||
// icon budget: small files pass through untouched (the server judges
|
||||
// the bytes, not the declared type); a large raster is scaled down
|
||||
// until it fits; a large SVG cannot be scaled and is refused here.
|
||||
async function fileToIconDataURL(file: File): Promise<string> {
|
||||
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
|
||||
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
|
||||
throw new Error('tooLarge');
|
||||
}
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
for (const size of [256, 180, 128, 96, 64]) {
|
||||
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) break;
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
|
||||
}
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
throw new Error('tooLarge');
|
||||
}
|
||||
|
||||
export default function PublicPageTab() {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [themes, setThemes] = useState<ThemeWire[]>([]);
|
||||
const [settings, setSettings] = useState<ProbeSettingsWire>({
|
||||
publicPage: false,
|
||||
siteName: 'Monitor',
|
||||
site: '',
|
||||
githubProxy: '',
|
||||
});
|
||||
const [favicon, setFavicon] = useState('');
|
||||
const [touchIcon, setTouchIcon] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [installURL, setInstallURL] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [uploadPct, setUploadPct] = useState<number | null>(null);
|
||||
const [busyShort, setBusyShort] = useState('');
|
||||
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
|
||||
const [coverage, setCoverage] = useState<CoverageRow[]>([]);
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
const [webhookHeaders, setWebhookHeaders] = useState('');
|
||||
const [urlTouched, setUrlTouched] = useState(false);
|
||||
@@ -162,14 +98,9 @@ export default function PublicPageTab() {
|
||||
const [notifySaving, setNotifySaving] = useState(false);
|
||||
const [notifyTesting, setNotifyTesting] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const faviconRef = useRef<HTMLInputElement>(null);
|
||||
const touchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const applyPayload = useCallback((payload: ThemesPayload) => {
|
||||
setThemes(payload.themes ?? []);
|
||||
setSettings(payload.settings);
|
||||
setFavicon(payload.favicon ?? '');
|
||||
setTouchIcon(payload.touchIcon ?? '');
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
@@ -185,7 +116,6 @@ export default function PublicPageTab() {
|
||||
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setNotify(msg.obj.settings);
|
||||
setCoverage(msg.obj.coverage ?? []);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -215,7 +145,6 @@ export default function PublicPageTab() {
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setNotify(msg.obj.settings);
|
||||
setCoverage(msg.obj.coverage ?? []);
|
||||
setWebhookUrl('');
|
||||
setWebhookHeaders('');
|
||||
setUrlTouched(false);
|
||||
@@ -247,32 +176,6 @@ export default function PublicPageTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.put(
|
||||
'/panel/api/probe/settings',
|
||||
{ ...settings, favicon, touchIcon },
|
||||
{ silent: true, ...JSON_HEADERS },
|
||||
);
|
||||
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
|
||||
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const url = await fileToIconDataURL(file);
|
||||
if (which === 'favicon') setFavicon(url);
|
||||
else setTouchIcon(url);
|
||||
} catch {
|
||||
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTheme(file: File | undefined) {
|
||||
if (!file) return;
|
||||
if (file.size === 0) {
|
||||
@@ -371,101 +274,7 @@ export default function PublicPageTab() {
|
||||
return (
|
||||
<div>
|
||||
{messageContextHolder}
|
||||
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
|
||||
<Switch
|
||||
checked={settings.publicPage}
|
||||
onChange={(v) => setSettings({ ...settings, publicPage: v })}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{t('pages.settings.probeThemes.publicPageHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
|
||||
<Input
|
||||
value={settings.siteName}
|
||||
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
|
||||
<Input
|
||||
value={settings.site}
|
||||
placeholder="https://"
|
||||
onChange={(e) => setSettings({ ...settings, site: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
|
||||
<Input
|
||||
value={settings.githubProxy}
|
||||
placeholder="https://"
|
||||
onChange={(e) => setSettings({ ...settings, githubProxy: e.target.value })}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.githubProxyHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
|
||||
<Space>
|
||||
{favicon ? <img src={favicon} alt="" style={{ width: 24, height: 24 }} /> : null}
|
||||
<Button size="small" onClick={() => faviconRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{favicon ? (
|
||||
<Button size="small" onClick={() => setFavicon('')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={faviconRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
|
||||
<Space>
|
||||
{touchIcon ? <img src={touchIcon} alt="" style={{ width: 24, height: 24 }} /> : null}
|
||||
<Button size="small" onClick={() => touchRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{touchIcon ? (
|
||||
<Button size="small" onClick={() => setTouchIcon('')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={touchRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.iconHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button type="primary" loading={saving} onClick={saveSettings}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
{t('pages.settings.probeThemes.title')}
|
||||
</Typography.Title>
|
||||
<Typography.Title level={5}>{t('pages.settings.probeThemes.title')}</Typography.Title>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
|
||||
{uploadPct === null
|
||||
@@ -745,53 +554,6 @@ export default function PublicPageTab() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
{t('pages.settings.probeNotify.retireTitle')}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.settings.probeNotify.retireHint')}
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical">
|
||||
<span>
|
||||
{t('pages.settings.probeNotify.retirePublicPage')}:
|
||||
{settings.publicPage ? (
|
||||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||||
) : (
|
||||
<Tag color="orange">{t('pages.settings.probeNotify.retirePublicPageOff')}</Tag>
|
||||
)}
|
||||
</span>
|
||||
{coverage.map((row) => (
|
||||
<span key={row.name}>
|
||||
{row.name}:
|
||||
{row.hasProbe ? (
|
||||
<Tag color="green">{t('pages.settings.probeNotify.retireHasProbe')}</Tag>
|
||||
) : (
|
||||
<Tag color="red">{t('pages.settings.probeNotify.retireNoProbe')}</Tag>
|
||||
)}
|
||||
{row.online ? (
|
||||
<Tag color="green">{t('pages.settings.probeNotify.retireOnline')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.settings.probeNotify.retireOffline')}</Tag>
|
||||
)}
|
||||
{row.probeAt > 0 ? (
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.retireProbeAt', {
|
||||
time: new Date(row.probeAt).toLocaleString(),
|
||||
})}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
{coverage.length > 0 && coverage.every((row) => row.hasProbe) ? (
|
||||
<Typography.Text type="success">
|
||||
{t('pages.settings.probeNotify.retireAllReady')}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="warning">
|
||||
{t('pages.settings.probeNotify.retireNotReady')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@ import SecurityTab from './SecurityTab';
|
||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||
import TelegramTab from './TelegramTab';
|
||||
import PublicPageTab from './PublicPageTab';
|
||||
import ProbeSettingsTab from './ProbeSettingsTab';
|
||||
import './SettingsPage.css';
|
||||
|
||||
interface ApiMsg {
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page'];
|
||||
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page', 'probe'];
|
||||
|
||||
function isIp(h: string): boolean {
|
||||
if (typeof h !== 'string') return false;
|
||||
@@ -182,6 +183,8 @@ export default function SettingsPage() {
|
||||
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'public-page':
|
||||
return <PublicPageTab />;
|
||||
case 'probe':
|
||||
return <ProbeSettingsTab />;
|
||||
default:
|
||||
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
}
|
||||
@@ -247,7 +250,6 @@ export default function SettingsPage() {
|
||||
</Col>
|
||||
<Col xs={24} sm={14} className="header-info">
|
||||
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
|
||||
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
@@ -17,6 +17,9 @@ export const NodeRecordSchema = z
|
||||
cpuPct: z.number().optional(),
|
||||
memPct: z.number().optional(),
|
||||
xrayVersion: z.string().optional(),
|
||||
// Agent nodes run sing-box since V2: their core version is the
|
||||
// sing-box one the heartbeat reports (V2.0.1 ②).
|
||||
singboxVersion: z.string().optional(),
|
||||
panelVersion: z.string().optional(),
|
||||
uptimeSecs: z.number().optional(),
|
||||
inboundCount: z.number().optional(),
|
||||
@@ -53,6 +56,9 @@ export const NodeRecordSchema = z
|
||||
// P7: node kind, annotated by the backend for nodes registered by an
|
||||
// agent. Absent/traditional for classic panel-to-panel nodes.
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
// V2.1.0: probe-only agent nodes run no core, only the probe
|
||||
// channels. Annotated by the backend from the bound agent row.
|
||||
probeOnly: z.boolean().optional(),
|
||||
// P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
|
||||
// Rendered as plain text in the address column for agent rows.
|
||||
ipv4: z.string().optional(),
|
||||
@@ -98,6 +104,8 @@ export const NodeFormSchema = z
|
||||
// Agent nodes use an agent token instead of a panel API token.
|
||||
apiToken: z.string().trim(),
|
||||
hasStoredToken: z.boolean().optional().default(false),
|
||||
// V2.1.0: probe-only toggle for agent nodes (see NodeRecordSchema).
|
||||
probeOnly: z.boolean().optional().default(false),
|
||||
enable: z.boolean(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
|
||||
@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
|
||||
|
||||
exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
|
||||
|
||||
exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
|
||||
exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@override.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&flow=xtls-rprx-vision#parity-test"`;
|
||||
|
||||
exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
|
||||
|
||||
@@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202
|
||||
|
||||
exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@example.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
|
||||
|
||||
exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
|
||||
exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://22222222-3333-4444-9555-666666666666@example.test:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&flow=xtls-rprx-vision#parity-test"`;
|
||||
|
||||
exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { deriveSpiderX } from '@/lib/xray/spider-x';
|
||||
|
||||
// Cross-language vectors shared with TestDeriveSpiderXMatchesFrontendVectors
|
||||
// in internal/sub/service_sharelink_test.go: subscription links come from Go,
|
||||
// panel links from this module, and the two must agree byte-for-byte.
|
||||
describe('deriveSpiderX', () => {
|
||||
it('matches the Go deriveSpiderX vectors', () => {
|
||||
expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c');
|
||||
expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60');
|
||||
});
|
||||
|
||||
it('is stable per client, distinct across clients, and rotates with the seed', () => {
|
||||
expect(deriveSpiderX('/seed', 'subAlice')).toBe(deriveSpiderX('/seed', 'subAlice'));
|
||||
expect(deriveSpiderX('/seed', 'subAlice')).not.toBe(deriveSpiderX('/seed', 'subBob'));
|
||||
expect(deriveSpiderX('/seedA', 'subAlice')).not.toBe(deriveSpiderX('/seedB', 'subAlice'));
|
||||
});
|
||||
|
||||
it('returns empty when there is nothing to derive from', () => {
|
||||
expect(deriveSpiderX('', '')).toBe('');
|
||||
});
|
||||
|
||||
it('emits a /-prefixed 15-hex-char path', () => {
|
||||
expect(deriveSpiderX('/some-seed', 'client@example.com')).toMatch(/^\/[0-9a-f]{15}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Installs the 3x-ui probe-only agent on Windows (64-bit).
|
||||
|
||||
.DESCRIPTION
|
||||
Downloads x-ui-agent-windows-amd64.exe into C:\x-ui, writes
|
||||
agent.json with "probeOnly": true (Windows nodes run no proxy
|
||||
core: facts, metrics and latency probes only), registers the
|
||||
x-ui-agent Windows service (auto-start) and starts it.
|
||||
|
||||
Re-running is safe: an existing service is stopped and removed
|
||||
first, and any of -Master/-Token/-Name left empty is inherited
|
||||
from the agent.json already on disk.
|
||||
|
||||
.EXAMPLE
|
||||
.\install-agent.ps1 -Master "https://panel.example.com/rogue" -Token "<agent token>" -Name "win-node-1"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Master,
|
||||
[string]$Token,
|
||||
[string]$Name,
|
||||
[string]$InstallDir = "C:\x-ui",
|
||||
[string]$AgentUrl = $env:XUI_URL
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Release download base, same shape as install-agent.sh's
|
||||
# RELEASE_BASE (the version segment is bumped by the release flow).
|
||||
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.1"
|
||||
if (-not $AgentUrl) {
|
||||
$AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe"
|
||||
}
|
||||
|
||||
$ServiceName = "x-ui-agent"
|
||||
$ExeName = "x-ui-agent-windows-amd64.exe"
|
||||
$ExePath = Join-Path $InstallDir $ExeName
|
||||
$ConfigPath = Join-Path $InstallDir "agent.json"
|
||||
|
||||
# Inherit missing parameters from an existing agent.json (reruns).
|
||||
if (Test-Path $ConfigPath) {
|
||||
$existing = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
if (-not $Master) { $Master = $existing.master_url }
|
||||
if (-not $Token) { $Token = $existing.token }
|
||||
if (-not $Name) { $Name = $existing.node_name }
|
||||
}
|
||||
if (-not $Master) { throw "missing -Master (and no existing agent.json to inherit from)" }
|
||||
if (-not $Token) { throw "missing -Token (and no existing agent.json to inherit from)" }
|
||||
if (-not $Name) { throw "missing -Name (and no existing agent.json to inherit from)" }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
|
||||
# Stop and remove any previous install so the exe file is not locked
|
||||
# by a running service when it is replaced below. Prefer the old
|
||||
# binary's own subcommands; fall back to sc.exe when it is gone.
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($svc) {
|
||||
if (Test-Path $ExePath) {
|
||||
& $ExePath stop | Out-Null
|
||||
& $ExePath uninstall | Out-Null
|
||||
}
|
||||
else {
|
||||
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
|
||||
& sc.exe delete $ServiceName | Out-Null
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
# Download the agent binary (via a temp file, then swap into place).
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$tmpExe = "$ExePath.new"
|
||||
Write-Host "Downloading $AgentUrl"
|
||||
Invoke-WebRequest -Uri $AgentUrl -OutFile $tmpExe -UseBasicParsing
|
||||
if (-not (Test-Path $tmpExe) -or (Get-Item $tmpExe).Length -eq 0) {
|
||||
throw "agent download produced an empty file: $AgentUrl"
|
||||
}
|
||||
Move-Item -Force $tmpExe $ExePath
|
||||
|
||||
# Write agent.json. Windows agents are probe-only: probeOnly is
|
||||
# always set (the design contract, not an option).
|
||||
$config = [ordered]@{
|
||||
master_url = $Master
|
||||
token = $Token
|
||||
node_name = $Name
|
||||
probeOnly = $true
|
||||
}
|
||||
$json = $config | ConvertTo-Json
|
||||
# Go's encoding/json rejects a UTF-8 BOM, which Set-Content -Encoding
|
||||
# UTF8 would add on Windows PowerShell 5.1: write BOM-less UTF-8.
|
||||
[System.IO.File]::WriteAllText($ConfigPath, $json, (New-Object System.Text.UTF8Encoding($false)))
|
||||
Write-Host "Wrote $ConfigPath (probeOnly)"
|
||||
|
||||
# Register and start the service.
|
||||
& $ExePath install -config $ConfigPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "service install failed (exit $LASTEXITCODE)" }
|
||||
& $ExePath start
|
||||
if ($LASTEXITCODE -ne 0) { throw "service start failed (exit $LASTEXITCODE)" }
|
||||
|
||||
Write-Host "3x-ui agent installed and running as service '$ServiceName'."
|
||||
Write-Host "Update later by re-running this script; the master's one-click agent update is intentionally not consumed on Windows."
|
||||
+85
-8
@@ -1,6 +1,8 @@
|
||||
#!/bin/bash
|
||||
# 3x-ui-agent 节点一键安装脚本
|
||||
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
|
||||
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] [--probe-only]
|
||||
# --probe-only(V2.1.0):纯探针安装——跳过 sing-box 核心下载与自检,
|
||||
# agent.json 写入 probeOnly=true;agent 只跑探针通道、不运行任何核心。
|
||||
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
|
||||
#
|
||||
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
|
||||
@@ -9,7 +11,7 @@
|
||||
#
|
||||
# 测试用环境变量(一般无需设置):
|
||||
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | procd | none,默认 auto 探测
|
||||
# XUI_URL agent 二进制下载地址覆盖
|
||||
#
|
||||
# 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用
|
||||
@@ -28,12 +30,13 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
[[ $EUID -ne 0 ]] && error "请用 root 运行"
|
||||
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
|
||||
|
||||
MASTER=""; TOKEN=""; NAME=""
|
||||
MASTER=""; TOKEN=""; NAME=""; PROBE_ONLY=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--master) MASTER="$2"; shift 2;;
|
||||
--token) TOKEN="$2"; shift 2;;
|
||||
--name) NAME="$2"; shift 2;;
|
||||
--probe-only) PROBE_ONLY=1; shift;;
|
||||
*) error "未知参数: $1";;
|
||||
esac
|
||||
done
|
||||
@@ -53,6 +56,8 @@ if [[ -f "$AGENT_JSON_PATH" ]]; then
|
||||
if [[ -z "$MASTER" && -n "$f_master" ]]; then MASTER="$f_master"; INHERITED+=("主控地址"); fi
|
||||
if [[ -z "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); fi
|
||||
if [[ -z "$NAME" && -n "$f_name" ]]; then NAME="$f_name"; INHERITED+=("节点名"); fi
|
||||
# probeOnly 是布尔字段,json_get 只取字符串值,这里单独按 true 沿用。
|
||||
if [[ -z "$PROBE_ONLY" ]] && grep -q '"probeOnly"[[:space:]]*:[[:space:]]*true' "$AGENT_JSON_PATH"; then PROBE_ONLY=1; INHERITED+=("纯探针"); fi
|
||||
if [[ ${#INHERITED[@]} -gt 0 ]]; then
|
||||
info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)"
|
||||
elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then
|
||||
@@ -76,7 +81,7 @@ esac
|
||||
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
|
||||
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
|
||||
# 重装节点(节点升级走面板一键更新,不需要本脚本)。
|
||||
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.0"
|
||||
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.1"
|
||||
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
|
||||
|
||||
ROOT="${XUI_ROOT:-}"
|
||||
@@ -107,18 +112,30 @@ mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
|
||||
SINGBOX_VER="1.13.21"
|
||||
SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}"
|
||||
SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}"
|
||||
if [[ "$PROBE_ONLY" == "1" ]]; then
|
||||
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
|
||||
# 本机已有的核心文件不删(关掉纯探针后可直接复用)。
|
||||
info "纯探针模式:跳过 sing-box 核心下载"
|
||||
else
|
||||
info "下载 sing-box v${SINGBOX_VER}..."
|
||||
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
|
||||
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
|
||||
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
|
||||
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
|
||||
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
|
||||
fi
|
||||
|
||||
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
|
||||
# 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
|
||||
# agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
|
||||
# 写配置/起服务阶段。
|
||||
self_check() {
|
||||
if [[ "$PROBE_ONLY" == "1" ]]; then
|
||||
# 纯探针节点没有核心可检(V2.1.0),只验 agent 本体。
|
||||
[[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}"
|
||||
info "纯探针模式:跳过 sing-box 自检"
|
||||
return 0
|
||||
fi
|
||||
local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
|
||||
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
|
||||
local sb_out
|
||||
@@ -137,11 +154,14 @@ self_check() {
|
||||
self_check
|
||||
|
||||
info "写入 agent 配置..."
|
||||
PROBE_ONLY_JSON=""
|
||||
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
|
||||
"probeOnly": true'; fi
|
||||
cat > "${CONF_DIR}/agent.json" <<EOF
|
||||
{
|
||||
"master_url": "$MASTER",
|
||||
"token": "$TOKEN",
|
||||
"node_name": "$NAME"
|
||||
"node_name": "$NAME"${PROBE_ONLY_JSON}
|
||||
}
|
||||
EOF
|
||||
chmod 600 "${CONF_DIR}/agent.json"
|
||||
@@ -200,7 +220,7 @@ stop_legacy_agent() {
|
||||
|
||||
detect_init_system() {
|
||||
case "${XUI_INIT_SYSTEM:-auto}" in
|
||||
systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
systemd|openrc|procd|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
esac
|
||||
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo systemd; return
|
||||
@@ -208,6 +228,9 @@ detect_init_system() {
|
||||
if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then
|
||||
echo openrc; return
|
||||
fi
|
||||
if [ -f /etc/rc.common ] && command -v procd >/dev/null 2>&1; then
|
||||
echo procd; return
|
||||
fi
|
||||
echo none
|
||||
}
|
||||
|
||||
@@ -266,10 +289,14 @@ command_args="agent"
|
||||
# supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起,
|
||||
# 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background
|
||||
# 只负责后台化、崩溃不重启。respawn_max=0 = 不限次(同 systemd 的持续重试)。
|
||||
# respawn_period 必须为正数:supervise-daemon 启动时校验该值,0 直接报
|
||||
# "Invalid respawn-period value '0'" 拒绝拉起(V2.2.1 ⑤,Alpine 真机实证);
|
||||
# 取 1800(真机上其他 supervise-daemon 服务同值)。max=0 不限次时 period
|
||||
# 仅是计数窗口,不影响持续重试语义。
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
respawn_period=0
|
||||
respawn_period=1800
|
||||
pidfile="/run/x-ui-agent.pid"
|
||||
output_log="/var/log/x-ui-agent.log"
|
||||
error_log="/var/log/x-ui-agent.log"
|
||||
@@ -302,9 +329,58 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# 以 procd 服务方式安装并启动(开机自启,OpenWrt)
|
||||
start_via_procd() {
|
||||
local init_dir="${ROOT}/etc/init.d"
|
||||
mkdir -p "$init_dir"
|
||||
# 服务脚本与 2026-10-23 AX6600 真机手工验证版逐字一致(V2.2.1 ④
|
||||
# 收编):路径按脚本变量展开,XUI_ROOT 为空时即验证版的
|
||||
# /usr/local/x-ui/xui-agent 与 /etc/x-ui/agent.json。procd 托管
|
||||
# + respawn 守护,stdout/stderr 进 syslog(logread 查看)。
|
||||
cat > "${init_dir}/xui-agent" <<EOF
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=99
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command ${AGENT_BIN} agent -config ${CONF_DIR}/agent.json
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
EOF
|
||||
chmod +x "${init_dir}/xui-agent"
|
||||
info "已生成 procd 服务文件: ${init_dir}/xui-agent"
|
||||
if [ -n "$ROOT" ]; then
|
||||
info "(XUI_ROOT 模式:跳过 /etc/init.d 实际操作)"
|
||||
return 0
|
||||
fi
|
||||
/etc/init.d/xui-agent stop 2>/dev/null || true
|
||||
stop_legacy_agent
|
||||
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
|
||||
/etc/init.d/xui-agent enable
|
||||
/etc/init.d/xui-agent start
|
||||
sleep 2
|
||||
if pgrep -f "${AGENT_BIN} agent" >/dev/null 2>&1 || ps w 2>/dev/null | grep "[x]ui-agent agent" >/dev/null 2>&1; then
|
||||
info "agent 已由 procd 托管启动(开机自启)"
|
||||
echo ""
|
||||
echo " 主控: $MASTER"
|
||||
echo " 节点名: $NAME"
|
||||
echo " 日志: logread -f"
|
||||
echo " agent 会在 30 秒内从主控拉取配置"
|
||||
else
|
||||
error "agent 服务启动失败,查看: logread | tail -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
# 无服务管理器时回落 nohup(无开机自启,明确警告)
|
||||
start_via_nohup() {
|
||||
warn "未检测到 systemd / OpenRC,退回 nohup 方式启动"
|
||||
# BusyBox 精简系统可能连 nohup 都没有(AX6600 实证):先讲清再退,
|
||||
# 不要假装启动、走到最后才报一个看不懂的失败。
|
||||
command -v nohup >/dev/null 2>&1 || error "未检测到 systemd / OpenRC / procd,且本机无 nohup 命令,无法启动 agent"
|
||||
warn "未检测到 systemd / OpenRC / procd,退回 nohup 方式启动"
|
||||
warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!"
|
||||
stop_legacy_agent
|
||||
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
|
||||
@@ -328,5 +404,6 @@ info "启动 agent..."
|
||||
case "$(detect_init_system)" in
|
||||
systemd) start_via_systemd;;
|
||||
openrc) start_via_openrc;;
|
||||
procd) start_via_procd;;
|
||||
*) start_via_nohup;;
|
||||
esac
|
||||
|
||||
+177
-2
@@ -55,6 +55,11 @@ type Config struct {
|
||||
// Empty: main derives it next to the agent config file; the agent
|
||||
// falls back to the bin folder.
|
||||
BaselinePath string `json:"baseline_path,omitempty"`
|
||||
// ProbeOnly presets probe-only mode locally (V2.1.0): no core is
|
||||
// fetched, started or stopped for the node's inbounds — only the
|
||||
// probe channels run. The master's per-node flag (delivered on the
|
||||
// config poll) enables the same mode; either source turns it on.
|
||||
ProbeOnly bool `json:"probeOnly,omitempty"`
|
||||
}
|
||||
|
||||
// Agent represents the node agent
|
||||
@@ -113,6 +118,22 @@ type Agent struct {
|
||||
sbVerFor string
|
||||
sbVerMod time.Time
|
||||
|
||||
// Core fetch backoff (V2.0.1 ①, see singbox_fetch.go).
|
||||
sbFetchMu sync.Mutex
|
||||
sbFetchFailedAt time.Time
|
||||
|
||||
// Fast-report state (V2.0.1 probe batch): the cadence and
|
||||
// counted-interface pin delivered on the config poll. A zero
|
||||
// fastInterval means the master never offered one (pre-V2.0.1
|
||||
// master) and metrics keep riding the 60 s heartbeat.
|
||||
fastIntervalSec atomic.Int64
|
||||
fastUnsupported atomic.Bool
|
||||
|
||||
// Probe-only mode (V2.1.0): the master's per-node flag rides the
|
||||
// config poll; the local config preset is the other source. While
|
||||
// either is on, the poll skips both core branches entirely.
|
||||
masterProbeOnly atomic.Bool
|
||||
|
||||
// Latency probe runner (item 7 B4, see ping.go): task set pushed
|
||||
// on the config poll, samples returned on the heartbeat.
|
||||
probes *probeRunner
|
||||
@@ -278,10 +299,18 @@ func (a *Agent) Run() error {
|
||||
trafficTicker := time.NewTicker(defaultTrafficInterval)
|
||||
defer trafficTicker.Stop()
|
||||
|
||||
// Fast-report timer (V2.0.1): armed by the config poll once the
|
||||
// master offers a cadence; disarmed while the master is older
|
||||
// (no cadence offered) or answered 404 (endpoint unknown).
|
||||
fastTimer := time.NewTimer(time.Hour)
|
||||
fastTimer.Stop()
|
||||
defer fastTimer.Stop()
|
||||
|
||||
// Initial config fetch
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
logger.Warningf("Initial config fetch failed: %v", err)
|
||||
}
|
||||
a.armFastTimer(fastTimer)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -298,14 +327,69 @@ func (a *Agent) Run() error {
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
logger.Warningf("Config fetch failed: %v", err)
|
||||
}
|
||||
a.armFastTimer(fastTimer)
|
||||
case <-trafficTicker.C:
|
||||
if err := a.collectAndReportTraffic(); err != nil {
|
||||
logger.Warningf("Traffic report failed: %v", err)
|
||||
}
|
||||
case <-fastTimer.C:
|
||||
a.sendFastReport()
|
||||
a.armFastTimer(fastTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// armFastTimer (re)schedules the fast report from the current
|
||||
// master-provided cadence. Not offered (0) or unsupported (404
|
||||
// seen) leaves the timer stopped: metrics then ride the heartbeat,
|
||||
// the pre-V2.0.1 arrangement.
|
||||
func (a *Agent) armFastTimer(t *time.Timer) {
|
||||
sec := a.fastIntervalSec.Load()
|
||||
if sec <= 0 || a.fastUnsupported.Load() {
|
||||
t.Stop()
|
||||
return
|
||||
}
|
||||
t.Reset(time.Duration(sec) * time.Second)
|
||||
}
|
||||
|
||||
// fastReportActive reports whether runtime metrics currently ride
|
||||
// the fast channel (and so must stay off the heartbeat).
|
||||
func (a *Agent) fastReportActive() bool {
|
||||
return a.fastIntervalSec.Load() > 0 && !a.fastUnsupported.Load()
|
||||
}
|
||||
|
||||
// sendFastReport posts one metrics-only report (V2.0.1). Failures
|
||||
// are quiet by design — the stream is high-cadence and the next
|
||||
// tick retries; a 404 marks the endpoint unsupported (pre-V2.0.1
|
||||
// master) and metrics stay on the heartbeat from then on.
|
||||
func (a *Agent) sendFastReport() {
|
||||
m := collectMetrics()
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"probeMetrics": m})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
url := a.config.MasterURL + "/panel/api/agent/probe-report"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
a.fastUnsupported.Store(true)
|
||||
logger.Warning("master does not serve the probe fast-report endpoint; metrics stay on the heartbeat")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the agent
|
||||
func (a *Agent) Stop() {
|
||||
if a.probes != nil {
|
||||
@@ -338,7 +422,15 @@ func (a *Agent) sendHeartbeat() error {
|
||||
// copy; metrics are sampled fresh each heartbeat. Old masters
|
||||
// ignore both fields.
|
||||
"probeFacts": collectFacts(),
|
||||
"probeMetrics": collectMetrics(),
|
||||
}
|
||||
// V2.0.1: runtime metrics ride the fast report whenever the
|
||||
// master offered a cadence and the endpoint answered; the
|
||||
// heartbeat then carries only the heavy, slow-moving content
|
||||
// (facts, telemetry, probe results, update status). Without a
|
||||
// fast channel the metrics stay here — a pre-V2.0.1 master must
|
||||
// keep receiving them exactly as before.
|
||||
if !a.fastReportActive() {
|
||||
payload["probeMetrics"] = collectMetrics()
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep != nil {
|
||||
payload["updateStatus"] = rep
|
||||
@@ -417,11 +509,38 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
// current set, while a present-but-empty list means "run no
|
||||
// probes" and clears it.
|
||||
ProbeTasks *[]ProbeTask `json:"probe_tasks"`
|
||||
// V2.0.1 probe batch: fast-report cadence + counted-interface
|
||||
// pin. Pointers for the same reason — an old master omits
|
||||
// both and the agent keeps its current behaviour (no fast
|
||||
// report, automatic interface selection).
|
||||
ProbeFastInterval *int64 `json:"probe_fast_interval"`
|
||||
ProbeStatIface *string `json:"probe_stat_iface"`
|
||||
// ProbeOnly is a pointer on purpose (V2.1.0), same as the other
|
||||
// probe keys: a master too old to know the mode omits the field
|
||||
// and the agent keeps its current state.
|
||||
ProbeOnly *bool `json:"probe_only"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.ProbeFastInterval != nil {
|
||||
sec := *result.ProbeFastInterval
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
if sec > 60 {
|
||||
sec = 60
|
||||
}
|
||||
a.fastIntervalSec.Store(sec)
|
||||
}
|
||||
if result.ProbeStatIface != nil {
|
||||
SetStatIfacePin(*result.ProbeStatIface)
|
||||
}
|
||||
if result.ProbeOnly != nil {
|
||||
a.masterProbeOnly.Store(*result.ProbeOnly)
|
||||
}
|
||||
|
||||
// One-click update intents ride this poll (item 20); handle them even
|
||||
// when the config itself is unchanged.
|
||||
a.maybeHandleUpdate(result.AgentUpdate)
|
||||
@@ -432,13 +551,29 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
a.probes.applyTasks(*result.ProbeTasks)
|
||||
}
|
||||
|
||||
// Probe-only mode (V2.1.0): no core config is applied at all. The
|
||||
// master already withholds the payload for flagged nodes, but the
|
||||
// local preset must gate even against a master that still serves
|
||||
// one. A core left running from before the mode was turned on is
|
||||
// retired by the convergence step.
|
||||
if a.probeOnlyActive() {
|
||||
a.retireCoresForProbeOnly()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Switchover (ITEM6 B5): a master that serves a sing-box config
|
||||
// has made sing-box the node's formal runtime. Apply the config
|
||||
// through the switch sequence (which retires the legacy xray
|
||||
// core), and never touch the xray path again — a later poll that
|
||||
// somehow lacks the sing-box keys leaves both cores as they are
|
||||
// rather than resurrecting xray.
|
||||
if result.SingboxConfigHash != "" {
|
||||
//
|
||||
// V2.0.1 ① gate: the switch needs the sing-box core on disk. A
|
||||
// node whose core is missing gets one fetch attempt (throttled);
|
||||
// while it stays missing the switch is NOT latched and the xray
|
||||
// path below keeps the node current — nothing is retired for a
|
||||
// runtime that cannot start yet.
|
||||
if result.SingboxConfigHash != "" && a.singboxCoreReady() {
|
||||
a.sbSwitched = true
|
||||
if result.SingboxConfigHash != a.lastSingboxHash {
|
||||
if err := a.switchApplySingbox(result.SingboxConfig); err != nil {
|
||||
@@ -490,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeOnlyActive reports whether probe-only mode is in force from
|
||||
// either source: the local agent.json preset or the master's
|
||||
// per-node flag on the config poll.
|
||||
func (a *Agent) probeOnlyActive() bool {
|
||||
return a.config.ProbeOnly || a.masterProbeOnly.Load()
|
||||
}
|
||||
|
||||
// retireCoresForProbeOnly converges a node that entered probe-only
|
||||
// mode while a core was still running: the core's tail traffic is
|
||||
// flushed best-effort (an explicit operator mode change outranks the
|
||||
// unreported tail, unlike the switchover retire path, which keeps
|
||||
// the core alive on a failed flush) and the core is stopped. Later
|
||||
// polls find nothing running and skip, so the retirement happens
|
||||
// exactly once per entry. The switchover bookkeeping (sbSwitched,
|
||||
// lastSingboxHash) is left untouched on purpose: leaving probe-only
|
||||
// re-serves the same config hash, and the existing "hash current but
|
||||
// core not running" poll path reactivates the core on its own.
|
||||
func (a *Agent) retireCoresForProbeOnly() {
|
||||
if a.xrayProc != nil {
|
||||
if err := a.collectAndReportXrayTraffic(); err != nil {
|
||||
logger.Warningf("probe-only: flush xray traffic before retiring its core failed: %v", err)
|
||||
}
|
||||
a.stopXray()
|
||||
logger.Info("probe-only mode: xray core retired")
|
||||
}
|
||||
if a.singboxRunning() {
|
||||
if err := a.collectAndReportSingboxTraffic(); err != nil {
|
||||
logger.Warningf("probe-only: flush sing-box traffic before retiring its core failed: %v", err)
|
||||
}
|
||||
a.stopSingbox()
|
||||
logger.Info("probe-only mode: sing-box core retired")
|
||||
}
|
||||
}
|
||||
|
||||
// singboxRunning reports whether the managed sing-box process is
|
||||
// currently alive.
|
||||
func (a *Agent) singboxRunning() bool {
|
||||
@@ -806,6 +975,12 @@ func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error
|
||||
// (xray and, since ITEM6 B3, sing-box). The two cores keep separate
|
||||
// clients and baselines; a failure in one never blocks the other.
|
||||
func (a *Agent) collectAndReportTraffic() error {
|
||||
// Probe-only nodes run no core (V2.1.0): the cycle is an explicit
|
||||
// no-op, so a core retired by the mode can never be mistaken for
|
||||
// a live one by a later tick.
|
||||
if a.probeOnlyActive() {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
if err := a.collectAndReportXrayTraffic(); err != nil {
|
||||
firstErr = err
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// V2.0.1 probe batch, agent side: the config poll's cadence + pin
|
||||
// are adopted; while the fast channel is active the heartbeat stops
|
||||
// carrying metrics (they ride the fast report instead); a master
|
||||
// without the endpoint (404) sends the agent back to heartbeat
|
||||
// metrics permanently.
|
||||
|
||||
type fastMaster struct {
|
||||
mu sync.Mutex
|
||||
heartbeat []byte
|
||||
fastReport []byte
|
||||
fastStatus int
|
||||
configBody string
|
||||
reportStatus int
|
||||
}
|
||||
|
||||
func (m *fastMaster) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
m.mu.Lock()
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/config"):
|
||||
m.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(m.configBody))
|
||||
case strings.HasSuffix(r.URL.Path, "/probe-report"):
|
||||
m.fastReport = body
|
||||
st := m.fastStatus
|
||||
m.mu.Unlock()
|
||||
if st != 0 && st != http.StatusOK {
|
||||
w.WriteHeader(st)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/heartbeat"):
|
||||
m.heartbeat = body
|
||||
m.mu.Unlock()
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
default:
|
||||
m.mu.Unlock()
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFastReportSplitAndFallback(t *testing.T) {
|
||||
SetStatIfacePin("")
|
||||
t.Cleanup(func() { SetStatIfacePin("") })
|
||||
|
||||
master := &fastMaster{configBody: `{"probe_fast_interval":5,"probe_stat_iface":"eth7"}`}
|
||||
srv := httptest.NewServer(master.handler())
|
||||
defer srv.Close()
|
||||
|
||||
a := &Agent{
|
||||
config: &Config{MasterURL: srv.URL, Token: "tok", NodeName: "n"},
|
||||
httpClient: http.DefaultClient,
|
||||
probes: newProbeRunner(),
|
||||
}
|
||||
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("fetchAndApplyConfig: %v", err)
|
||||
}
|
||||
if got := a.fastIntervalSec.Load(); got != 5 {
|
||||
t.Fatalf("fast interval = %d, want 5 from the poll", got)
|
||||
}
|
||||
if got := statIfacePinValue(); got != "eth7" {
|
||||
t.Fatalf("stat iface pin = %q, want eth7", got)
|
||||
}
|
||||
if !a.fastReportActive() {
|
||||
t.Fatal("fast report should be active after the poll offered a cadence")
|
||||
}
|
||||
|
||||
// The fast report carries the metrics…
|
||||
a.sendFastReport()
|
||||
master.mu.Lock()
|
||||
fast := master.fastReport
|
||||
master.mu.Unlock()
|
||||
var fastBody map[string]json.RawMessage
|
||||
if err := json.Unmarshal(fast, &fastBody); err != nil || fastBody["probeMetrics"] == nil {
|
||||
t.Fatalf("fast report = %s (err %v), want a probeMetrics block", fast, err)
|
||||
}
|
||||
|
||||
// …and the heartbeat no longer does.
|
||||
if err := a.sendHeartbeat(); err != nil {
|
||||
t.Fatalf("sendHeartbeat: %v", err)
|
||||
}
|
||||
master.mu.Lock()
|
||||
hb := master.heartbeat
|
||||
master.mu.Unlock()
|
||||
var hbBody map[string]json.RawMessage
|
||||
if err := json.Unmarshal(hb, &hbBody); err != nil {
|
||||
t.Fatalf("heartbeat body: %v", err)
|
||||
}
|
||||
if hbBody["probeMetrics"] != nil {
|
||||
t.Error("heartbeat still carries probeMetrics while the fast channel is active")
|
||||
}
|
||||
if hbBody["probeFacts"] == nil || hbBody["telemetry"] == nil {
|
||||
t.Error("heartbeat lost its heavy content (facts/telemetry)")
|
||||
}
|
||||
|
||||
// A 404 from the fast endpoint (old master) disables the fast
|
||||
// channel and restores heartbeat metrics.
|
||||
master.mu.Lock()
|
||||
master.fastStatus = http.StatusNotFound
|
||||
master.mu.Unlock()
|
||||
a.sendFastReport()
|
||||
if a.fastReportActive() {
|
||||
t.Fatal("fast report still active after a 404")
|
||||
}
|
||||
if err := a.sendHeartbeat(); err != nil {
|
||||
t.Fatalf("sendHeartbeat after fallback: %v", err)
|
||||
}
|
||||
master.mu.Lock()
|
||||
hb = master.heartbeat
|
||||
master.mu.Unlock()
|
||||
hbBody = map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(hb, &hbBody); err != nil {
|
||||
t.Fatalf("heartbeat body after fallback: %v", err)
|
||||
}
|
||||
if hbBody["probeMetrics"] == nil {
|
||||
t.Error("heartbeat did not resume carrying probeMetrics after the 404 fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// The counted-interface pin: strict single-interface selection, and
|
||||
// a pin naming a vanished interface counts nothing (so the epoch
|
||||
// changes and the master re-baselines instead of booking garbage).
|
||||
func TestPinnedInterfaces(t *testing.T) {
|
||||
all := []netCounters{
|
||||
{name: "eth0", rx: 100, tx: 200},
|
||||
{name: "ens3", rx: 300, tx: 400},
|
||||
}
|
||||
got := pinnedInterfaces(all, "ens3")
|
||||
if len(got) != 1 || got[0].name != "ens3" || got[0].rx != 300 {
|
||||
t.Errorf("pinnedInterfaces = %+v, want only ens3", got)
|
||||
}
|
||||
if got := pinnedInterfaces(all, "gone0"); len(got) != 0 {
|
||||
t.Errorf("pin for a vanished interface = %+v, want empty", got)
|
||||
}
|
||||
}
|
||||
+55
-765
@@ -1,33 +1,34 @@
|
||||
package agent
|
||||
|
||||
// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go:
|
||||
// host Facts (once per process) and per-heartbeat Metrics read directly from
|
||||
// /proc and /sys with the standard library only. The port keeps the source's
|
||||
// probe.go is the platform-neutral core of the probe collector, ported
|
||||
// from monitor-probe's agent collector (collect.rs, MIT): host Facts
|
||||
// (once per process) and per-heartbeat Metrics with the source's
|
||||
// accounting discipline:
|
||||
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
|
||||
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
|
||||
// - network totals sum only "counted" interfaces — loopback, container,
|
||||
// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer
|
||||
// filter so no byte is counted twice;
|
||||
// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set,
|
||||
// so a set change re-baselines exactly like a reboot;
|
||||
// - every source degrades independently: an unreadable file yields a zero
|
||||
// field, never a failed collection round.
|
||||
// - memory "used" is free(1)'s column: total - available;
|
||||
// - disk "used" is df(1)'s: real storage only, total - free blocks
|
||||
// (not the unprivileged-available figure);
|
||||
// - network totals sum only "counted" interfaces — loopback,
|
||||
// container, tunnel and stacked devices are excluded by a filter so
|
||||
// no byte is counted twice;
|
||||
// - boot epoch = a stable boot identity + FNV-1a fingerprint of the
|
||||
// counted set, so a set change re-baselines exactly like a reboot;
|
||||
// - every source degrades independently: an unreadable source yields
|
||||
// a zero field, never a failed collection round.
|
||||
//
|
||||
// The samplers themselves are per-OS: probe_linux.go reads /proc and
|
||||
// /sys, probe_windows.go reads the Win32 performance/registry surface.
|
||||
// Both feed the shared rate/epoch machinery below and must keep the
|
||||
// same accounting semantics; where a source has no equivalent on the
|
||||
// platform, the field stays zero (documented at the sampler).
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
)
|
||||
|
||||
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled
|
||||
@@ -71,7 +72,7 @@ type ProbeMetrics struct {
|
||||
}
|
||||
|
||||
// probeCollector holds the cross-sample state the rates need (previous CPU
|
||||
// jiffies and per-interface counters). Kept in memory only, like the source.
|
||||
// counters and per-interface counters). Kept in memory only, like the source.
|
||||
type probeCollector struct {
|
||||
mu sync.Mutex
|
||||
prevCPU *cpuSample
|
||||
@@ -79,6 +80,9 @@ type probeCollector struct {
|
||||
prevNetT time.Time
|
||||
}
|
||||
|
||||
// cpuSample is one cumulative CPU reading in whatever tick unit the
|
||||
// platform reports: total covers every state, idle only the idle state.
|
||||
// The differencing in cpuPercent is unit-agnostic.
|
||||
type cpuSample struct{ total, idle uint64 }
|
||||
|
||||
var (
|
||||
@@ -93,169 +97,45 @@ func collectFacts() *ProbeFacts {
|
||||
return factsCache
|
||||
}
|
||||
|
||||
func sampleFacts() *ProbeFacts {
|
||||
mem := parseMeminfo(readProcFile("meminfo"))
|
||||
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
|
||||
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
|
||||
v4, v6 := probeAddresses()
|
||||
return &ProbeFacts{
|
||||
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
|
||||
OS: osPrettyName(),
|
||||
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
|
||||
Arch: runtime.GOARCH,
|
||||
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
|
||||
CPUName: cpuName,
|
||||
CPUCores: cpuCores,
|
||||
MemTotal: mem["MemTotal"],
|
||||
SwapTotal: mem["SwapTotal"],
|
||||
DiskTotal: diskTotal,
|
||||
AgentVersion: config.GetAgentDisplayVersion(),
|
||||
IPv4: v4,
|
||||
IPv6: v6,
|
||||
}
|
||||
}
|
||||
// statIfacePin (V2.0.1) names the single interface to count when
|
||||
// the operator pinned one on the master (its probe_meta.stat_iface,
|
||||
// delivered on the config poll). "" keeps the automatic counted-set
|
||||
// selection. The pin bypasses the filter on purpose: the operator
|
||||
// named this exact device, including when it is one the filter would
|
||||
// skip (a tunnel the node actually bills on). A pinned name absent
|
||||
// from the interface table counts nothing — the boot epoch changes
|
||||
// with the counted set, so the master re-baselines instead of booking
|
||||
// a phantom delta.
|
||||
var statIfacePin atomic.Value // string
|
||||
|
||||
// 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")))
|
||||
counted := countedInterfaces(parseNetDev(readProcFile("net/dev")), sysNetRoot)
|
||||
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(),
|
||||
}
|
||||
}
|
||||
// SetStatIfacePin records the master's counted-interface pin.
|
||||
func SetStatIfacePin(name string) { statIfacePin.Store(name) }
|
||||
|
||||
// --- file helpers -----------------------------------------------------------
|
||||
|
||||
const sysNetRoot = "/sys/class/net"
|
||||
|
||||
func readProcFile(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("/proc", name))
|
||||
if err != nil {
|
||||
func statIfacePinValue() string {
|
||||
if v := statIfacePin.Load(); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
|
||||
// netCounters is one interface's cumulative byte totals.
|
||||
type netCounters struct {
|
||||
name string
|
||||
rx, tx uint64
|
||||
}
|
||||
|
||||
func readTrimFile(path, fallback string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fallback
|
||||
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
|
||||
for _, c := range all {
|
||||
if c.name == pin {
|
||||
return []netCounters{c}
|
||||
}
|
||||
if s := strings.TrimSpace(string(b)); s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// --- memory (free口径) -------------------------------------------------------
|
||||
|
||||
func parseMeminfo(text string) map[string]uint64 {
|
||||
out := make(map[string]uint64)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(rest)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
kb, err := strconv.ParseUint(f[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[key] = kb * 1024
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
|
||||
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
|
||||
// available above the total, as LXCFS can report, -> total - MemFree).
|
||||
func memUsedFree(m map[string]uint64) (total, used uint64) {
|
||||
total = m["MemTotal"]
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
avail, ok := m["MemAvailable"]
|
||||
if !ok {
|
||||
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
|
||||
}
|
||||
if avail > total {
|
||||
avail = m["MemFree"]
|
||||
}
|
||||
if avail > total {
|
||||
avail = total
|
||||
}
|
||||
return total, total - avail
|
||||
}
|
||||
|
||||
func swapUsedFree(m map[string]uint64) (total, used uint64) {
|
||||
total = m["SwapTotal"]
|
||||
free := m["SwapFree"]
|
||||
if free > total {
|
||||
free = total
|
||||
}
|
||||
return total, total - free
|
||||
}
|
||||
|
||||
// --- cpu --------------------------------------------------------------------
|
||||
|
||||
func parseCPUJiffies(stat string) *cpuSample {
|
||||
for _, line := range strings.Split(stat, "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line[4:])
|
||||
if len(f) < 5 {
|
||||
return nil
|
||||
}
|
||||
var v [8]uint64
|
||||
var total uint64
|
||||
for i := 0; i < len(f) && i < 8; i++ {
|
||||
n, err := strconv.ParseUint(f[i], 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v[i] = n
|
||||
total += n
|
||||
}
|
||||
return &cpuSample{total: total, idle: v[3] + v[4]}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cpuPercent diffs two cumulative samples into a busy percentage. A
|
||||
// missing or non-advancing previous sample reports 0 (the round only
|
||||
// re-baselines) — the source's discipline on both platforms.
|
||||
func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
|
||||
if now == nil {
|
||||
return 0
|
||||
@@ -280,344 +160,14 @@ func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
|
||||
return pct
|
||||
}
|
||||
|
||||
func parseLoadavg(text string) [3]float32 {
|
||||
var out [3]float32
|
||||
f := strings.Fields(text)
|
||||
for i := 0; i < 3 && i < len(f); i++ {
|
||||
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
|
||||
out[i] = float32(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUptime(text string) uint64 {
|
||||
f := strings.Fields(text)
|
||||
if len(f) == 0 {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseFloat(f[0], 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(v)
|
||||
}
|
||||
|
||||
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
|
||||
func cpuInfo(text string) (string, uint32) {
|
||||
name := ""
|
||||
cores := uint32(0)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "processor":
|
||||
cores++
|
||||
case "model name", "Model", "cpu model":
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cores == 0 {
|
||||
cores = uint32(runtime.NumCPU())
|
||||
if cores == 0 {
|
||||
cores = 1
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = armCPUName(text)
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
return name, cores
|
||||
}
|
||||
|
||||
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
|
||||
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
|
||||
func armCPUName(text string) string {
|
||||
var vendor *armVendor
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "CPU implementer":
|
||||
vendor = nil
|
||||
if id, ok := parseHexID(val); ok {
|
||||
for i := range armVendors {
|
||||
if armVendors[i].id == id {
|
||||
vendor = &armVendors[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
case "CPU part":
|
||||
if vendor == nil {
|
||||
continue
|
||||
}
|
||||
name := vendor.name
|
||||
if id, ok := parseHexID(val); ok {
|
||||
for _, p := range vendor.parts {
|
||||
if p.id == id {
|
||||
name = p.name
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(names, "/")
|
||||
}
|
||||
|
||||
func parseHexID(s string) (uint32, bool) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
|
||||
v, err := strconv.ParseUint(s, 16, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(v), true
|
||||
}
|
||||
|
||||
type armPart struct {
|
||||
id uint32
|
||||
name string
|
||||
}
|
||||
type armVendor struct {
|
||||
id uint32
|
||||
name string
|
||||
parts []armPart
|
||||
}
|
||||
|
||||
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
|
||||
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
|
||||
var armVendors = []armVendor{
|
||||
{0x41, "ARM", []armPart{
|
||||
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
|
||||
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
|
||||
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
|
||||
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
|
||||
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
|
||||
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
|
||||
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
|
||||
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
|
||||
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
|
||||
}},
|
||||
{0x48, "HiSilicon", []armPart{
|
||||
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
|
||||
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
|
||||
}},
|
||||
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
|
||||
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
|
||||
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
|
||||
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
|
||||
}
|
||||
|
||||
// --- os / virt ---------------------------------------------------------------
|
||||
|
||||
func osPrettyName() string {
|
||||
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
||||
return strings.Trim(v, "\"'")
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
// detectVirt ports the source's chain: containers first, then hypervisor.
|
||||
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
|
||||
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
|
||||
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
|
||||
// reads as "none", same caveat the source documents for cleared CPUID bits.
|
||||
func detectVirt(mounts, cpuinfo string) string {
|
||||
if _, err := os.Stat("/proc/vz"); err == nil {
|
||||
if _, err := os.Stat("/proc/bc"); err != nil {
|
||||
return "openvz"
|
||||
}
|
||||
}
|
||||
if s := readTrimFile("/run/systemd/container", ""); s != "" {
|
||||
return s
|
||||
}
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return "docker"
|
||||
}
|
||||
if _, err := os.Stat("/run/.containerenv"); err == nil {
|
||||
return "podman"
|
||||
}
|
||||
for _, line := range strings.Split(mounts, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
|
||||
return "lxc"
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/proc/xen"); err == nil {
|
||||
return "xen"
|
||||
}
|
||||
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
|
||||
return "xen"
|
||||
}
|
||||
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
|
||||
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
|
||||
for _, m := range []struct{ word, name string }{
|
||||
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
|
||||
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
|
||||
{"bhyve", "bhyve"}, {"xen", "xen"},
|
||||
} {
|
||||
if strings.Contains(dmi, m.word) {
|
||||
return m.name
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
|
||||
return "kvm"
|
||||
}
|
||||
if strings.Contains(cpuinfo, "hypervisor") {
|
||||
return "vm"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// --- network: counted set, epoch, rates -------------------------------------
|
||||
|
||||
type netCounters struct {
|
||||
name string
|
||||
rx, tx uint64
|
||||
}
|
||||
|
||||
func parseNetDev(text string) []netCounters {
|
||||
var out []netCounters
|
||||
lines := strings.Split(text, "\n")
|
||||
for _, line := range lines {
|
||||
name, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(rest)
|
||||
if len(f) < 16 {
|
||||
continue
|
||||
}
|
||||
rx, err1 := strconv.ParseUint(f[0], 10, 64)
|
||||
tx, err2 := strconv.ParseUint(f[8], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
|
||||
var skipIfacePrefixes = []string{
|
||||
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
|
||||
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
|
||||
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
|
||||
}
|
||||
|
||||
func skipIfaceName(name string) bool {
|
||||
for _, p := range skipIfacePrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
|
||||
// the kernel books their bytes again on the device beneath.
|
||||
func isStackedName(name string) bool {
|
||||
if strings.Contains(name, ".") {
|
||||
return true
|
||||
}
|
||||
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var tunnelLinkTypes = map[string]bool{
|
||||
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
|
||||
}
|
||||
|
||||
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
|
||||
// are already counted on another device. Hardware (a `device` link) exempts
|
||||
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
|
||||
// type with WireGuard but carries real traffic.
|
||||
func countedElsewhere(sysRoot, name string) bool {
|
||||
dev := filepath.Join(sysRoot, name)
|
||||
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
|
||||
devtypeIs := func(set ...string) bool {
|
||||
for _, line := range strings.Split(uevent, "\n") {
|
||||
for _, want := range set {
|
||||
if line == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
|
||||
return true
|
||||
}
|
||||
if entries, err := os.ReadDir(dev); err == nil {
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "lower_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
|
||||
return true
|
||||
}
|
||||
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
|
||||
return true
|
||||
}
|
||||
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
|
||||
}
|
||||
|
||||
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
|
||||
func ifaceCounts(sysRoot, name string) bool {
|
||||
if skipIfaceName(name) || isStackedName(name) {
|
||||
return false
|
||||
}
|
||||
return !countedElsewhere(sysRoot, name)
|
||||
}
|
||||
|
||||
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
|
||||
out := make([]netCounters, 0, len(all))
|
||||
for _, c := range all {
|
||||
if ifaceCounts(sysRoot, c.name) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bootEpoch = "{kernel boot_id}/{FNV-1a-64 of the sorted counted names}".
|
||||
// bootEpoch = "{boot identity}/{FNV-1a-64 of the sorted counted names}".
|
||||
// The master only tests it for equality: any change re-baselines.
|
||||
func bootEpoch(kernelBootID string, names []string) string {
|
||||
func bootEpoch(bootID string, names []string) string {
|
||||
sorted := append([]string(nil), names...)
|
||||
sort.Strings(sorted)
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(strings.Join(sorted, "\n")))
|
||||
return kernelBootID + "/" + hex16(h.Sum64())
|
||||
return bootID + "/" + hex16(h.Sum64())
|
||||
}
|
||||
|
||||
func hex16(v uint64) string {
|
||||
@@ -662,136 +212,6 @@ func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64,
|
||||
return uint64(float64(rx) / secs), uint64(float64(tx) / secs)
|
||||
}
|
||||
|
||||
// --- addresses (scope §1.5) ---------------------------------------------------
|
||||
|
||||
// probeAddresses picks one address per family: candidates come from
|
||||
// non-skip interfaces that are up, are not link-local, prefer public over
|
||||
// private, and for v6 avoid temporary/deprecated/tentative addresses.
|
||||
func probeAddresses() (string, string) {
|
||||
transient := transientV6(readProcFile("net/if_inet6"))
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
type cand struct {
|
||||
ip net.IP
|
||||
transient bool
|
||||
}
|
||||
var held []cand
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
|
||||
continue
|
||||
}
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
continue
|
||||
}
|
||||
if !lendsAddress(ifc.Name, ip) {
|
||||
continue
|
||||
}
|
||||
c := cand{ip: ip}
|
||||
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
|
||||
for _, t := range transient {
|
||||
if t.Equal(ip6) {
|
||||
c.transient = true
|
||||
}
|
||||
}
|
||||
}
|
||||
held = append(held, c)
|
||||
}
|
||||
}
|
||||
pick := func(v6 bool) string {
|
||||
var best *cand
|
||||
for i := range held {
|
||||
c := &held[i]
|
||||
if (c.ip.To4() == nil) != v6 {
|
||||
continue
|
||||
}
|
||||
if best == nil {
|
||||
best = c
|
||||
continue
|
||||
}
|
||||
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
|
||||
if cp != bp {
|
||||
if cp {
|
||||
best = c
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v6 && best.transient && !c.transient {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return ""
|
||||
}
|
||||
return best.ip.String()
|
||||
}
|
||||
return pick(false), pick(true)
|
||||
}
|
||||
|
||||
// lendsAddress mirrors the source: a skipped interface lends no address,
|
||||
// except a WireGuard interface lending a public v6 that is not in
|
||||
// Cloudflare WARP's shared 2606:4700::/32.
|
||||
func lendsAddress(name string, ip net.IP) bool {
|
||||
if !skipIfaceName(name) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
|
||||
ip16 := ip.To16()
|
||||
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// transientV6 parses /proc/net/if_inet6 for addresses carrying
|
||||
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
|
||||
func transientV6(text string) []net.IP {
|
||||
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
|
||||
var out []net.IP
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 6 {
|
||||
continue
|
||||
}
|
||||
flags, err := strconv.ParseUint(f[4], 16, 32)
|
||||
if err != nil || flags&transientFlags == 0 {
|
||||
continue
|
||||
}
|
||||
if len(f[0]) != 32 {
|
||||
continue
|
||||
}
|
||||
ip := make(net.IP, 16)
|
||||
ok := true
|
||||
for i := 0; i < 16; i++ {
|
||||
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
|
||||
if err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
ip[i] = byte(b)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, ip)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isPublicIP mirrors monitor's is_public: v4 excludes RFC1918, CGNAT
|
||||
// 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
|
||||
// v6 is only 2000::/3.
|
||||
@@ -818,133 +238,3 @@ func isPublicIP(ip net.IP) bool {
|
||||
}
|
||||
return ip16[0]&0xe0 == 0x20
|
||||
}
|
||||
|
||||
// --- sockets / processes ------------------------------------------------------
|
||||
|
||||
func connCounts(v4, v6 string) (tcp, udp uint32) {
|
||||
stat := func(text, prefix, key string) uint32 {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line[len(prefix):])
|
||||
for i := 0; i+1 < len(f); i++ {
|
||||
if f[i] == key {
|
||||
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
|
||||
return uint32(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
|
||||
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
|
||||
return tcp, udp
|
||||
}
|
||||
|
||||
func procCount() uint32 {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var n uint32
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
digits := name != ""
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] < '0' || name[i] > '9' {
|
||||
digits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if digits {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- disk (df口径) ---------------------------------------------------------------
|
||||
|
||||
var skipFSTypes = map[string]bool{
|
||||
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
|
||||
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
|
||||
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
|
||||
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
|
||||
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
|
||||
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
|
||||
"fuse": true, "rpc_pipefs": true,
|
||||
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
|
||||
"glusterfs": true, "9p": true,
|
||||
}
|
||||
|
||||
func skipFSType(fstype string) bool {
|
||||
if skipFSTypes[fstype] {
|
||||
return true
|
||||
}
|
||||
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
|
||||
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// realMountPoints ports parse_mounts: only the topmost mount per path,
|
||||
// deduped by source device (zfs by pool), real storage only.
|
||||
func realMountPoints(text string) []string {
|
||||
type row struct{ dev, mount, fstype string }
|
||||
var rows []row
|
||||
top := map[string]int{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row{f[0], f[1], f[2]})
|
||||
top[f[1]] = len(rows) - 1
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for i, r := range rows {
|
||||
if top[r.mount] != i || skipFSType(r.fstype) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
|
||||
continue
|
||||
}
|
||||
key := r.dev
|
||||
if r.fstype == "zfs" {
|
||||
if j := strings.IndexByte(key, '/'); j >= 0 {
|
||||
key = key[:j]
|
||||
}
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// diskUsage sums statfs over the mounts: total = f_blocks, used =
|
||||
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
|
||||
func diskUsage(mounts []string) (total, used uint64) {
|
||||
for _, m := range mounts {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(m, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
bs := uint64(st.Frsize)
|
||||
if bs == 0 {
|
||||
bs = uint64(st.Bsize)
|
||||
}
|
||||
total += st.Blocks * bs
|
||||
if st.Blocks > st.Bfree {
|
||||
used += (st.Blocks - st.Bfree) * bs
|
||||
}
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// This file holds the probe collector's degrade-don't-die plumbing:
|
||||
// the per-source independence discipline as reusable mechanics,
|
||||
// shared by every platform sampler but exercised today by the Windows
|
||||
// sampler (the Linux sampler reads /proc files, whose failure mode is
|
||||
// an error return, not a panic).
|
||||
//
|
||||
// Two layers:
|
||||
//
|
||||
// - source gates: an optional OS facility (a Win32 procedure, say)
|
||||
// is probed once; when it is missing the source is skipped —
|
||||
// zeroed — with a single warning, never re-probed, and never
|
||||
// reached through a call path that panics on a missing export the
|
||||
// way LazyProc.Call does when it must-finds a procedure;
|
||||
// - guarded calls: a panicking source is recovered, zeroed and
|
||||
// logged, so one bad source can never take the agent process (and
|
||||
// every other source with it) down.
|
||||
|
||||
// sourceGate caches one availability probe. The zero value is ready.
|
||||
type sourceGate struct {
|
||||
once sync.Once
|
||||
available bool
|
||||
}
|
||||
|
||||
// enabled runs probe on first use and reports whether the source may
|
||||
// be used. probe returns nil when the facility is present. A failed
|
||||
// probe disables the source for the process lifetime and logs once.
|
||||
func (g *sourceGate) enabled(name string, probe func() error) bool {
|
||||
g.once.Do(func() {
|
||||
if err := probe(); err != nil {
|
||||
logger.Warningf("probe: %s unavailable, source disabled: %v", name, err)
|
||||
return
|
||||
}
|
||||
g.available = true
|
||||
})
|
||||
return g.available
|
||||
}
|
||||
|
||||
// guardedCall runs fn and converts a panic into the zero value plus a
|
||||
// warning, so one failing metrics source degrades alone.
|
||||
func guardedCall[T any](name string, fn func() T) (out T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Warningf("probe: %s panicked, source zeroed: %v", name, r)
|
||||
out = *new(T)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The degrade-don't-die mechanics behind V2.2.1 ①: on the trimmed
|
||||
// Tianyi cloud-PC image the Windows sampler died on its first
|
||||
// collection because a missing iphlpapi export made LazyProc.Call
|
||||
// panic. These tests pin the two layers that replace that failure
|
||||
// mode — a failed availability probe disables the source (probed
|
||||
// once, not per poll), and a panicking source is zeroed, never
|
||||
// propagated.
|
||||
|
||||
func TestSourceGateDisablesFailedProbeOnce(t *testing.T) {
|
||||
var g sourceGate
|
||||
calls := 0
|
||||
probe := func() error {
|
||||
calls++
|
||||
return errors.New("procedure not found")
|
||||
}
|
||||
for i := range 3 {
|
||||
if g.enabled("test-source", probe) {
|
||||
t.Fatalf("call %d: gate enabled despite failed probe", i)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceGateEnablesSuccessfulProbeOnce(t *testing.T) {
|
||||
var g sourceGate
|
||||
calls := 0
|
||||
probe := func() error {
|
||||
calls++
|
||||
return nil
|
||||
}
|
||||
for i := range 3 {
|
||||
if !g.enabled("test-source", probe) {
|
||||
t.Fatalf("call %d: gate disabled despite successful probe", i)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedCallRecoversPanicToZero(t *testing.T) {
|
||||
got := guardedCall("test-source", func() uint64 {
|
||||
panic("Failed to find FreeMibIfTable2 procedure in iphlpapi.dll")
|
||||
})
|
||||
if got != 0 {
|
||||
t.Fatalf("guardedCall after panic = %d, want zero value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedCallPassesValueThrough(t *testing.T) {
|
||||
if got := guardedCall("test-source", func() string { return "ok" }); got != "ok" {
|
||||
t.Fatalf("guardedCall = %q, want %q", got, "ok")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
// probe_windows.go is the Windows sampler for the probe collector,
|
||||
// feeding the same Facts/Metrics contract and accounting discipline
|
||||
// as the Linux sampler (see probe.go) from the Win32 surface:
|
||||
//
|
||||
// - CPU%: GetSystemTimes differenced through the shared cpuPercent
|
||||
// (kernel time includes idle, exactly like /proc/stat jiffies);
|
||||
// - memory: GlobalMemoryStatusEx, used = TotalPhys - AvailPhys
|
||||
// (the free(1) "available" column's spirit); swap is the pagefile
|
||||
// pair (TotalPageFile/AvailPageFile);
|
||||
// - disk: fixed drives only (GetDriveType == DRIVE_FIXED), summed
|
||||
// like df: used = total - free bytes (GetDiskFreeSpaceEx's
|
||||
// TotalFree, NOT the caller-available figure, which discounts
|
||||
// quotas the way f_bavail discounts the ext reserve);
|
||||
// - network: GetIfTable2 rows filtered to OperStatus Up, ifType
|
||||
// Ethernet (6) or IEEE80211 (71), non-loopback. This approximates
|
||||
// the Linux three-layer filter: Windows exposes no stacking
|
||||
// topology in the MIB, but tunnel/virtual adapters report their
|
||||
// own ifTypes (tunnel 131, loopback 24, ...), so an allowlist of
|
||||
// physical media lands on the same counted set in practice. The
|
||||
// cost of the difference: a physical-medium bond/virtual switch
|
||||
// vNIC presenting as Ethernet IS counted here even where Linux
|
||||
// layer 3 would have skipped it;
|
||||
// - boot identity: the registry MachineGuid stands in for the
|
||||
// kernel boot_id; the counted-set FNV fingerprint is shared, so a
|
||||
// NIC change re-baselines exactly like on Linux;
|
||||
// - uptime: GetTickCount64 (via x/sys' DurationSinceBoot wrapper);
|
||||
// - load averages, TCP/UDP connection counts have no exported
|
||||
// x/sys/windows (v0.47.0) source — GetSystemTimes-style raw
|
||||
// counters exist for CPU/memory, but the package exports no
|
||||
// GetTcpTable2/GetUdpTable2 wrapper, so per the independent-
|
||||
// degradation discipline Load stays zero and TCP/UDP stay 0
|
||||
// until a wrapper exists;
|
||||
// - every call fails soft to zeros, like an unreadable /proc file.
|
||||
// Optional procedures are resolved through source gates
|
||||
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
|
||||
// missing export, and trimmed images do ship DLLs without the
|
||||
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
|
||||
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
|
||||
// collection (V2.2.1 ①). A missing procedure now disables just
|
||||
// its source, and collectMetrics additionally guards each source
|
||||
// so a panic anywhere degrades that source alone.
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// kernel32 procs x/sys/windows v0.47.0 does not wrap, plus the
|
||||
// iphlpapi pair this file calls directly (gated below, so x/sys'
|
||||
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
|
||||
// missing export — is deliberately not used).
|
||||
var (
|
||||
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
|
||||
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
|
||||
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
|
||||
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
|
||||
)
|
||||
|
||||
// Availability gates for the optional procedures above. A LazyProc
|
||||
// must never be Call()ed before its gate has proven the export
|
||||
// exists: Call must-finds, so a missing procedure is a process-killing
|
||||
// panic, not an error.
|
||||
var (
|
||||
gateSystemTimes sourceGate
|
||||
gateMemoryStatus sourceGate
|
||||
gateIfTable sourceGate
|
||||
)
|
||||
|
||||
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
|
||||
// the export WITHOUT the mustFind panic baked into Call/Addr.
|
||||
func procProbe(p *windows.LazyProc) func() error {
|
||||
return func() error { return p.Find() }
|
||||
}
|
||||
|
||||
func sampleFacts() *ProbeFacts {
|
||||
memTotal, _, swapTotal, _ := winMemory()
|
||||
diskTotal, _ := winDiskUsage()
|
||||
v4, v6 := winAddresses()
|
||||
return &ProbeFacts{
|
||||
Hostname: winHostname(),
|
||||
OS: winOSName(),
|
||||
Kernel: winKernelBuild(),
|
||||
Arch: runtime.GOARCH,
|
||||
Virt: winVirt(),
|
||||
CPUName: winCPUName(),
|
||||
CPUCores: uint32(runtime.NumCPU()),
|
||||
MemTotal: memTotal,
|
||||
SwapTotal: swapTotal,
|
||||
DiskTotal: diskTotal,
|
||||
AgentVersion: config.GetAgentDisplayVersion(),
|
||||
IPv4: v4,
|
||||
IPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
// memSnapshot / diskSnapshot pack the multi-value samplers so each
|
||||
// can run as one guarded source.
|
||||
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
|
||||
type diskSnapshot struct{ total, used uint64 }
|
||||
|
||||
func collectMetrics() *ProbeMetrics {
|
||||
probeColl.mu.Lock()
|
||||
defer probeColl.mu.Unlock()
|
||||
// Every source runs guarded: a panic in one (a Win32 surface the
|
||||
// gates did not foresee) zeroes that source alone instead of
|
||||
// killing the agent on its first collection.
|
||||
mem := guardedCall("memory", func() memSnapshot {
|
||||
total, used, swapTotal, swapUsed := winMemory()
|
||||
return memSnapshot{total, used, swapTotal, swapUsed}
|
||||
})
|
||||
disk := guardedCall("disk", func() diskSnapshot {
|
||||
total, used := winDiskUsage()
|
||||
return diskSnapshot{total, used}
|
||||
})
|
||||
rows := guardedCall("interfaces", winIfaceRows)
|
||||
all := make([]netCounters, 0, len(rows))
|
||||
counted := make([]netCounters, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
c := netCounters{name: r.name, rx: r.rx, tx: r.tx}
|
||||
all = append(all, c)
|
||||
if winIfaceCounts(r) {
|
||||
counted = append(counted, c)
|
||||
}
|
||||
}
|
||||
if pin := statIfacePinValue(); pin != "" {
|
||||
counted = pinnedInterfaces(all, pin)
|
||||
}
|
||||
var rxTotal, txTotal uint64
|
||||
names := make([]string, 0, len(counted))
|
||||
for _, c := range counted {
|
||||
rxTotal += c.rx
|
||||
txTotal += c.tx
|
||||
names = append(names, c.name)
|
||||
}
|
||||
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
||||
return &ProbeMetrics{
|
||||
BootID: bootEpoch(winMachineGUID(), names),
|
||||
Uptime: guardedCall("uptime", func() uint64 {
|
||||
return uint64(windows.DurationSinceBoot().Seconds())
|
||||
}),
|
||||
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
|
||||
Load: [3]float32{},
|
||||
MemTotal: mem.total,
|
||||
MemUsed: mem.used,
|
||||
SwapTotal: mem.swapTotal,
|
||||
SwapUsed: mem.swapUsed,
|
||||
DiskTotal: disk.total,
|
||||
DiskUsed: disk.used,
|
||||
NetRxTotal: rxTotal,
|
||||
NetTxTotal: txTotal,
|
||||
NetRx: rxRate,
|
||||
NetTx: txRate,
|
||||
TCP: 0, // no exported x/sys wrapper (see header)
|
||||
UDP: 0, // no exported x/sys wrapper (see header)
|
||||
Procs: guardedCall("processes", winProcCount),
|
||||
}
|
||||
}
|
||||
|
||||
// --- registry helpers --------------------------------------------------------
|
||||
|
||||
func regGetString(root registry.Key, path, name string) string {
|
||||
k, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer k.Close()
|
||||
v, _, err := k.GetStringValue(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func winMachineGUID() string {
|
||||
return regGetString(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, "MachineGuid")
|
||||
}
|
||||
|
||||
func winOSName() string {
|
||||
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
|
||||
name := regGetString(registry.LOCAL_MACHINE, key, "ProductName")
|
||||
if name == "" {
|
||||
return "Windows"
|
||||
}
|
||||
// DisplayVersion ("23H2") disambiguates: ProductName still says
|
||||
// "Windows 10" on most Windows 11 installs.
|
||||
if dv := regGetString(registry.LOCAL_MACHINE, key, "DisplayVersion"); dv != "" {
|
||||
name += " " + dv
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func winKernelBuild() string {
|
||||
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
|
||||
if b := regGetString(registry.LOCAL_MACHINE, key, "CurrentBuild"); b != "" {
|
||||
return b
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func winCPUName() string {
|
||||
if n := regGetString(registry.LOCAL_MACHINE,
|
||||
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString"); n != "" {
|
||||
return n
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// winVirt is best-effort: the BIOS strings Windows itself surfaces.
|
||||
// No match reads "unknown" (Windows guests of a hypervisor that hides
|
||||
// them are indistinguishable from bare metal this way).
|
||||
func winVirt() string {
|
||||
const bios = `HARDWARE\DESCRIPTION\System\BIOS`
|
||||
hay := strings.ToLower(regGetString(registry.LOCAL_MACHINE, bios, "SystemManufacturer") +
|
||||
" " + regGetString(registry.LOCAL_MACHINE, bios, "SystemProductName"))
|
||||
for _, m := range []struct{ word, name string }{
|
||||
{"vmware", "vmware"}, {"virtualbox", "virtualbox"}, {"qemu", "kvm"},
|
||||
{"kvm", "kvm"}, {"xen", "xen"}, {"virtual machine", "hyper-v"},
|
||||
{"amazon ec2", "kvm"}, {"google", "kvm"}, {"bochs", "bochs"},
|
||||
} {
|
||||
if strings.Contains(hay, m.word) {
|
||||
return m.name
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func winHostname() string {
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// --- cpu / memory --------------------------------------------------------------
|
||||
|
||||
// filetime100ns flattens a FILETIME (100ns ticks) for the shared differ.
|
||||
func filetime100ns(ft windows.Filetime) uint64 {
|
||||
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
|
||||
}
|
||||
|
||||
// winCPUSample reads the cumulative system times as one cpuSample:
|
||||
// total = kernel + user (kernel already includes idle, like jiffies).
|
||||
func winCPUSample() *cpuSample {
|
||||
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
|
||||
return nil
|
||||
}
|
||||
var idle, kernel, user windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idle)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)))
|
||||
if r == 0 {
|
||||
return nil
|
||||
}
|
||||
return &cpuSample{
|
||||
total: filetime100ns(kernel) + filetime100ns(user),
|
||||
idle: filetime100ns(idle),
|
||||
}
|
||||
}
|
||||
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
// usedFromTotalAvail is the used-column discipline as a pure
|
||||
// function: used = total - avail, saturating at 0.
|
||||
func usedFromTotalAvail(total, avail uint64) uint64 {
|
||||
if avail > total {
|
||||
return 0
|
||||
}
|
||||
return total - avail
|
||||
}
|
||||
|
||||
func winMemory() (total, used, swapTotal, swapUsed uint64) {
|
||||
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
var ms memoryStatusEx
|
||||
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
|
||||
if r == 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
return ms.TotalPhys, usedFromTotalAvail(ms.TotalPhys, ms.AvailPhys),
|
||||
ms.TotalPageFile, usedFromTotalAvail(ms.TotalPageFile, ms.AvailPageFile)
|
||||
}
|
||||
|
||||
// --- disk (df口径) -----------------------------------------------------------
|
||||
|
||||
// driveSpace is one fixed drive's GetDiskFreeSpaceEx result.
|
||||
type driveSpace struct{ total, free uint64 }
|
||||
|
||||
// sumDriveSpace aggregates fixed drives the df way: used = total -
|
||||
// free (the caller-available figure is deliberately not consulted).
|
||||
func sumDriveSpace(ds []driveSpace) (total, used uint64) {
|
||||
for _, d := range ds {
|
||||
total += d.total
|
||||
used += usedFromTotalAvail(d.total, d.free)
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
// splitDriveStrings decodes the GetLogicalDriveStrings multi-string
|
||||
// buffer (NUL-separated roots, double-NUL terminated). Pure, so the
|
||||
// layout is unit-testable off-device.
|
||||
func splitDriveStrings(buf []uint16) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i, c := range buf {
|
||||
if c != 0 {
|
||||
continue
|
||||
}
|
||||
if i == start {
|
||||
break // double NUL: end of the list
|
||||
}
|
||||
out = append(out, windows.UTF16ToString(buf[start:i]))
|
||||
start = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fixedDriveRoots lists roots ("C:\") of DRIVE_FIXED drives.
|
||||
func fixedDriveRoots() []string {
|
||||
n, err := windows.GetLogicalDriveStrings(0, nil)
|
||||
if err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
buf := make([]uint16, n)
|
||||
n, err = windows.GetLogicalDriveStrings(n, &buf[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var roots []string
|
||||
for _, s := range splitDriveStrings(buf[:n]) {
|
||||
p, err := windows.UTF16PtrFromString(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if windows.GetDriveType(p) == windows.DRIVE_FIXED {
|
||||
roots = append(roots, s)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func winDiskUsage() (total, used uint64) {
|
||||
var spaces []driveSpace
|
||||
for _, root := range fixedDriveRoots() {
|
||||
p, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var freeToCaller, totalBytes, totalFree uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil {
|
||||
continue
|
||||
}
|
||||
spaces = append(spaces, driveSpace{total: totalBytes, free: totalFree})
|
||||
}
|
||||
return sumDriveSpace(spaces)
|
||||
}
|
||||
|
||||
// --- network: counted set ------------------------------------------------------
|
||||
|
||||
// winIfaceRow is the filter-relevant slice of one MIB_IF_ROW2.
|
||||
type winIfaceRow struct {
|
||||
name string
|
||||
ifType uint32
|
||||
operUp bool
|
||||
rx, tx uint64
|
||||
}
|
||||
|
||||
// winIfaceCounts applies the counted-set rule to one row (see the
|
||||
// file header for how it approximates the Linux three-layer filter).
|
||||
func winIfaceCounts(r winIfaceRow) bool {
|
||||
if !r.operUp {
|
||||
return false
|
||||
}
|
||||
if r.ifType == windows.IF_TYPE_SOFTWARE_LOOPBACK {
|
||||
return false
|
||||
}
|
||||
return r.ifType == windows.IF_TYPE_ETHERNET_CSMACD ||
|
||||
r.ifType == windows.IF_TYPE_IEEE80211
|
||||
}
|
||||
|
||||
// winIfaceRows reads the full MIB_IF_TABLE2 once; collectMetrics
|
||||
// derives both the all-interfaces list (for the operator pin) and the
|
||||
// counted set from it.
|
||||
func winIfaceRows() []winIfaceRow {
|
||||
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
|
||||
// without FreeMibIfTable2 would leak the system-allocated table
|
||||
// on every poll, and either export missing must degrade the
|
||||
// source to zero rows instead of panicking — the trimmed Tianyi
|
||||
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
|
||||
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
|
||||
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
|
||||
if err := procGetIfTable2Ex.Find(); err != nil {
|
||||
return err
|
||||
}
|
||||
return procFreeMibIfTable2.Find()
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
var table *windows.MibIfTable2
|
||||
r, _, _ := procGetIfTable2Ex.Call(
|
||||
uintptr(windows.MibIfTableNormal),
|
||||
uintptr(unsafe.Pointer(&table)))
|
||||
if r != 0 || table == nil {
|
||||
return nil
|
||||
}
|
||||
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
|
||||
n := int(table.NumEntries)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := unsafe.Slice(&table.Table[0], n)
|
||||
out := make([]winIfaceRow, 0, n)
|
||||
for i := range rows {
|
||||
r := &rows[i]
|
||||
out = append(out, winIfaceRow{
|
||||
name: windows.UTF16ToString(r.Alias[:]),
|
||||
ifType: r.Type,
|
||||
operUp: r.OperStatus == windows.IfOperStatusUp,
|
||||
rx: r.InOctets,
|
||||
tx: r.OutOctets,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- processes -----------------------------------------------------------------
|
||||
|
||||
func winProcCount() uint32 {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
var pe windows.ProcessEntry32
|
||||
pe.Size = uint32(unsafe.Sizeof(pe))
|
||||
if err := windows.Process32First(snap, &pe); err != nil {
|
||||
return 0
|
||||
}
|
||||
var n uint32 = 1
|
||||
for {
|
||||
if err := windows.Process32Next(snap, &pe); err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- addresses -------------------------------------------------------------------
|
||||
|
||||
// winAddresses picks one address per family from up, non-loopback
|
||||
// interfaces: public preferred over private, like the Linux picker —
|
||||
// only without its /proc inputs (no operstate file, no temporary-
|
||||
// address flags; net.Interfaces' own flags do the loopback cut).
|
||||
func winAddresses() (string, string) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var cands []net.IP
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, ip)
|
||||
}
|
||||
}
|
||||
pick := func(v6 bool) string {
|
||||
var best net.IP
|
||||
for _, ip := range cands {
|
||||
if (ip.To4() == nil) != v6 {
|
||||
continue
|
||||
}
|
||||
if best == nil {
|
||||
best = ip
|
||||
continue
|
||||
}
|
||||
if isPublicIP(ip) && !isPublicIP(best) {
|
||||
best = ip
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return ""
|
||||
}
|
||||
return best.String()
|
||||
}
|
||||
return pick(false), pick(true)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,28 @@ func (p *singboxProcess) epoch() string {
|
||||
return fmt.Sprintf("sing-box:%d:%d", p.cmd.Process.Pid, p.startedAt.UnixNano())
|
||||
}
|
||||
|
||||
// singboxCoreEnv is the environment the sing-box core process runs
|
||||
// with (V2.0.1 ⑧): a 32 MiB Go memory limit and GOGC=50, so the
|
||||
// core's heap is collected back instead of only growing — the
|
||||
// untuned core on HW-SG idles around 30 MB RSS where the tuned
|
||||
// NexusGate nodes sit under 10 MB. Every core launch goes through
|
||||
// singboxProcess.start, so the first start and every re-apply /
|
||||
// restart inherit this. Only the child is tuned: the agent's own
|
||||
// runtime settings are untouched, and values the agent itself was
|
||||
// started with are replaced, not duplicated (a duplicate key's
|
||||
// effective value would depend on the reader).
|
||||
func singboxCoreEnv() []string {
|
||||
env := os.Environ()
|
||||
out := make([]string, 0, len(env)+2)
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, "GOMEMLIMIT=") || strings.HasPrefix(kv, "GOGC=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return append(out, "GOMEMLIMIT=32MiB", "GOGC=50")
|
||||
}
|
||||
|
||||
// start launches sing-box with the given config file, appending its
|
||||
// output to the log file the online tracker tails.
|
||||
func (p *singboxProcess) start(binPath, configPath, logPath string) error {
|
||||
@@ -214,6 +236,7 @@ func (p *singboxProcess) start(binPath, configPath, logPath string) error {
|
||||
return fmt.Errorf("open sing-box log: %w", err)
|
||||
}
|
||||
cmd := exec.Command(binPath, "run", "-c", configPath)
|
||||
cmd.Env = singboxCoreEnv()
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// V2.0.1 ⑧: the sing-box core process is launched with
|
||||
// GOMEMLIMIT=32MiB and GOGC=50 in its environment — on the first
|
||||
// start and on every re-apply, since all launches go through
|
||||
// singboxProcess.start. The agent's own env is not modified, and
|
||||
// values the agent was itself started with are replaced (never
|
||||
// duplicated) for the child.
|
||||
|
||||
func countEnv(env []string, want string) int {
|
||||
n := 0
|
||||
for _, kv := range env {
|
||||
if kv == want {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestSingboxCoreEnv(t *testing.T) {
|
||||
t.Setenv("GOMEMLIMIT", "999MiB")
|
||||
t.Setenv("GOGC", "off")
|
||||
env := singboxCoreEnv()
|
||||
if n := countEnv(env, "GOMEMLIMIT=32MiB"); n != 1 {
|
||||
t.Errorf("GOMEMLIMIT=32MiB appears %d times, want exactly 1 (%v)", n, env)
|
||||
}
|
||||
if n := countEnv(env, "GOGC=50"); n != 1 {
|
||||
t.Errorf("GOGC=50 appears %d times, want exactly 1", n)
|
||||
}
|
||||
for _, kv := range env {
|
||||
if kv == "GOMEMLIMIT=999MiB" || kv == "GOGC=off" {
|
||||
t.Errorf("agent's own %q leaked into the core env", kv)
|
||||
}
|
||||
}
|
||||
// The agent process env itself is untouched.
|
||||
if got := os.Getenv("GOMEMLIMIT"); got != "999MiB" {
|
||||
t.Errorf("agent env GOMEMLIMIT = %q, want the untouched 999MiB", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSingboxProcessStartCarriesCoreEnv starts a stand-in core
|
||||
// through the real launch path and reads back the environment the
|
||||
// child actually received (the script dumps its own env on start).
|
||||
func TestSingboxProcessStartCarriesCoreEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dump := filepath.Join(dir, "child-env.txt")
|
||||
binPath := filepath.Join(dir, "sing-box")
|
||||
script := "#!/bin/sh\nenv > '" + dump + "'\nexec sleep 3600\n"
|
||||
if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake core: %v", err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, "cfg.json")
|
||||
if err := os.WriteFile(cfgPath, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("GOMEMLIMIT", "999MiB")
|
||||
p := &singboxProcess{}
|
||||
if err := p.start(binPath, cfgPath, filepath.Join(dir, "sing-box.log")); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer p.stop()
|
||||
|
||||
var childEnv []string
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if raw, err := os.ReadFile(dump); err == nil && len(raw) > 0 {
|
||||
childEnv = strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if childEnv == nil {
|
||||
t.Fatal("fake core never dumped its environment")
|
||||
}
|
||||
if n := countEnv(childEnv, "GOMEMLIMIT=32MiB"); n != 1 {
|
||||
t.Errorf("child env GOMEMLIMIT=32MiB x%d, want 1 (%v)", n, childEnv)
|
||||
}
|
||||
if n := countEnv(childEnv, "GOGC=50"); n != 1 {
|
||||
t.Errorf("child env GOGC=50 x%d, want 1", n)
|
||||
}
|
||||
if n := countEnv(childEnv, "GOMEMLIMIT=999MiB"); n != 0 {
|
||||
t.Errorf("child inherited the agent's GOMEMLIMIT=999MiB (%v)", childEnv)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// ITEM V2.0.1 ①: agents upgraded through the panel's one-click
|
||||
// update swap only their own binary — a node installed before the
|
||||
// V2 switchover can hold no sing-box core at all, and applying the
|
||||
// master's sing-box config there would strand it (the switch retires
|
||||
// xray first). Before any switch is attempted, a missing core is
|
||||
// fetched from the same Gitea release install-agent.sh installs
|
||||
// from (the NexusGate repo's dedicated sing-box-v1.13.21 release),
|
||||
// self-checked, and only then does the switch proceed. Any failure
|
||||
// leaves the node exactly as it was: xray keeps running, the fetch
|
||||
// retries on a later poll (throttled), and the failure is visible in
|
||||
// the agent log and — through the still-empty singboxVersion — on
|
||||
// the master's node list.
|
||||
|
||||
// singboxCoreVersion is the pinned self-built core release, the
|
||||
// same pin install-agent.sh carries (SINGBOX_VER).
|
||||
const singboxCoreVersion = "1.13.21"
|
||||
|
||||
// singboxCoreBaseURL is the release download base. A package
|
||||
// variable so tests can point it at a local server.
|
||||
var singboxCoreBaseURL = "https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v" + singboxCoreVersion
|
||||
|
||||
// singboxFetchBackoff suppresses immediate re-downloads after a
|
||||
// failed fetch: a node whose route to the release host is broken
|
||||
// must not hammer it every config poll.
|
||||
const singboxFetchBackoff = 10 * time.Minute
|
||||
|
||||
// singboxCoreAssetName is the release asset for this agent's
|
||||
// architecture (the release publishes linux amd64/arm64 only, the
|
||||
// same set install-agent.sh supports).
|
||||
func singboxCoreAssetName() (string, error) {
|
||||
switch runtime.GOARCH {
|
||||
case "amd64", "arm64":
|
||||
return "nexusgate-sing-box-linux-" + runtime.GOARCH, nil
|
||||
}
|
||||
return "", fmt.Errorf("no sing-box core asset for architecture %s", runtime.GOARCH)
|
||||
}
|
||||
|
||||
// singboxCoreReady reports whether the node can run sing-box: the
|
||||
// core is on disk, or a fetch just installed it. Callers on the
|
||||
// config path use it as the switch gate.
|
||||
func (a *Agent) singboxCoreReady() bool {
|
||||
if singboxBinaryPath() != "" {
|
||||
return true
|
||||
}
|
||||
_ = a.fetchSingboxCoreThrottled()
|
||||
return singboxBinaryPath() != ""
|
||||
}
|
||||
|
||||
// fetchSingboxCoreThrottled runs one fetch attempt unless the last
|
||||
// failure is still inside the backoff window. A failed attempt is
|
||||
// logged once (here, not per poll) and stays visible on the master
|
||||
// through the agent's still-empty singboxVersion telemetry.
|
||||
func (a *Agent) fetchSingboxCoreThrottled() error {
|
||||
a.sbFetchMu.Lock()
|
||||
defer a.sbFetchMu.Unlock()
|
||||
if !a.sbFetchFailedAt.IsZero() && time.Since(a.sbFetchFailedAt) < singboxFetchBackoff {
|
||||
return fmt.Errorf("sing-box core fetch in backoff after failure at %s", a.sbFetchFailedAt.Format(time.RFC3339))
|
||||
}
|
||||
if err := a.fetchSingboxCore(); err != nil {
|
||||
a.sbFetchFailedAt = time.Now()
|
||||
logger.Warningf("sing-box core fetch failed, staying on the current runtime (retry after backoff): %v", err)
|
||||
return err
|
||||
}
|
||||
a.sbFetchFailedAt = time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchSingboxCore downloads the pinned core into the bin folder,
|
||||
// self-checks it (a runnable `version`, mirroring the install
|
||||
// script's closing self-check), and only then moves it into place
|
||||
// and writes the install-time version record the telemetry reads.
|
||||
func (a *Agent) fetchSingboxCore() error {
|
||||
asset, err := singboxCoreAssetName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binDir := config.GetBinFolderPath()
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create bin folder: %w", err)
|
||||
}
|
||||
dest := filepath.Join(binDir, singboxBinaryName())
|
||||
tmp := dest + ".download"
|
||||
_ = os.Remove(tmp)
|
||||
|
||||
url := strings.TrimSuffix(singboxCoreBaseURL, "/") + "/" + asset
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var transport http.RoundTripper
|
||||
if a.httpClient != nil {
|
||||
transport = a.httpClient.Transport
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 5 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download sing-box core: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download sing-box core: status %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create sing-box core download: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("download sing-box core: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("download sing-box core: %w", err)
|
||||
}
|
||||
|
||||
// Self-check before anything is moved into place: the binary
|
||||
// must actually run on this node (right architecture, not a
|
||||
// truncated or error-page download).
|
||||
vctx, vcancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
out, verr := exec.CommandContext(vctx, tmp, "version").CombinedOutput()
|
||||
vcancel()
|
||||
if verr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("sing-box core self-check failed: %v (%s)", verr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if len(strings.TrimSpace(string(out))) == 0 {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("sing-box core self-check failed: version produced no output")
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("install sing-box core: %w", err)
|
||||
}
|
||||
// The version record is what telemetry reports for the self-built
|
||||
// core (its `version` output carries no stamp) — same convention
|
||||
// as install-agent.sh.
|
||||
_ = os.WriteFile(filepath.Join(binDir, singboxVersionRecord), []byte(singboxCoreVersion+"\n"), 0o644)
|
||||
logger.Infof("sing-box core %s fetched from the release and self-checked; ready to switch", singboxCoreVersion)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
)
|
||||
|
||||
// V2.0.1 ①: a node missing its sing-box core fetches the pinned
|
||||
// release build, self-checks it, and only then reports ready. A
|
||||
// failed fetch installs nothing and backs off instead of hammering
|
||||
// the release host on every config poll.
|
||||
|
||||
// fakeFetchedCore mimics the self-built release binary: it runs,
|
||||
// and its `version` carries no stamp ("unknown") — the shape that
|
||||
// makes telemetry fall back to the install-time version record.
|
||||
const fakeFetchedCore = "#!/bin/sh\necho 'sing-box version unknown'\n"
|
||||
|
||||
func fakeCoreServer(t *testing.T, hits *atomic.Int32, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
asset, err := singboxCoreAssetName()
|
||||
if err != nil {
|
||||
t.Skipf("no core asset for this arch: %v", err)
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, asset) {
|
||||
_, _ = w.Write([]byte(fakeFetchedCore))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
func useCoreBase(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
orig := singboxCoreBaseURL
|
||||
singboxCoreBaseURL = url
|
||||
t.Cleanup(func() { singboxCoreBaseURL = orig })
|
||||
}
|
||||
|
||||
func TestFetchSingboxCoreInstallsAndSelfChecks(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
var hits atomic.Int32
|
||||
srv := fakeCoreServer(t, &hits, http.StatusOK)
|
||||
defer srv.Close()
|
||||
useCoreBase(t, srv.URL)
|
||||
|
||||
a := &Agent{httpClient: http.DefaultClient}
|
||||
if !a.singboxCoreReady() {
|
||||
t.Fatal("core not ready after fetch")
|
||||
}
|
||||
dest := filepath.Join(binDir, singboxBinaryName())
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("core not installed: %v", err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Errorf("installed core is not executable: %v", info.Mode())
|
||||
}
|
||||
if got, ok := a.cachedSingboxVersion(); !ok || got != singboxCoreVersion {
|
||||
t.Errorf("reported core version = %q (ok %v), want %q (from the version record)", got, ok, singboxCoreVersion)
|
||||
}
|
||||
if config.GetBinFolderPath() != binDir {
|
||||
t.Fatalf("bin folder override not in effect")
|
||||
}
|
||||
// Second call: binary present, no further download.
|
||||
if !a.singboxCoreReady() || hits.Load() != 1 {
|
||||
t.Errorf("ready check re-downloaded: hits = %d", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSingboxCoreFailureKeepsNothingAndBacksOff(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
var hits atomic.Int32
|
||||
srv := fakeCoreServer(t, &hits, http.StatusBadGateway)
|
||||
defer srv.Close()
|
||||
useCoreBase(t, srv.URL)
|
||||
|
||||
a := &Agent{httpClient: http.DefaultClient}
|
||||
if a.singboxCoreReady() {
|
||||
t.Fatal("core reported ready after a failed fetch")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(binDir, singboxBinaryName())); !os.IsNotExist(err) {
|
||||
t.Errorf("failed fetch left a core binary behind: %v", err)
|
||||
}
|
||||
// Backoff: an immediate retry must not hit the server again.
|
||||
if err := a.fetchSingboxCoreThrottled(); err == nil {
|
||||
t.Fatal("throttled retry returned no error")
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Errorf("backoff violated: server hits = %d, want 1", hits.Load())
|
||||
}
|
||||
// And the switch gate stays closed: ready remains false.
|
||||
if a.singboxCoreReady() {
|
||||
t.Error("core became ready during backoff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSingboxCoreRejectsBrokenDownload(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = w.Write([]byte("this is not a binary at all"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
useCoreBase(t, srv.URL)
|
||||
|
||||
a := &Agent{httpClient: http.DefaultClient}
|
||||
if err := a.fetchSingboxCore(); err == nil {
|
||||
t.Fatal("broken download passed the self-check")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(binDir, singboxBinaryName())); !os.IsNotExist(err) {
|
||||
t.Errorf("broken download was installed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// V2.0.1 ① integration: the master serves a sing-box config but the
|
||||
// node has no core and the fetch fails — the switch must NOT latch
|
||||
// (xray stays the runtime and the next poll retries), because the
|
||||
// B5 switch retires xray the moment it engages.
|
||||
func TestSwitchGateStaysOpenWhenCoreFetchFails(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
|
||||
var hits atomic.Int32
|
||||
coreSrv := fakeCoreServer(t, &hits, http.StatusBadGateway)
|
||||
defer coreSrv.Close()
|
||||
useCoreBase(t, coreSrv.URL)
|
||||
|
||||
master := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/config") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"config_hash":"x1","singbox_config_hash":"sb1",` +
|
||||
`"singbox_config":{"inbounds":[]}}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer master.Close()
|
||||
|
||||
a := &Agent{
|
||||
config: &Config{MasterURL: master.URL, Token: "tok", NodeName: "n"},
|
||||
httpClient: http.DefaultClient,
|
||||
probes: newProbeRunner(),
|
||||
}
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("fetchAndApplyConfig: %v", err)
|
||||
}
|
||||
if a.sbSwitched {
|
||||
t.Fatal("switch latched although the sing-box core could not be fetched")
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Errorf("core fetch attempts = %d, want exactly 1", hits.Load())
|
||||
}
|
||||
// A second poll inside the backoff window must not re-download.
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("second fetchAndApplyConfig: %v", err)
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Errorf("backoff violated on the config path: hits = %d", hits.Load())
|
||||
}
|
||||
if a.sbSwitched {
|
||||
t.Fatal("switch latched during backoff")
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,15 @@ var execSelf = func(path string) error {
|
||||
return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ())
|
||||
}
|
||||
|
||||
// selfUpdateSupported reports whether this platform can consume a
|
||||
// master update intent. Windows agents are the standalone
|
||||
// xui-agent-windows binary (see cmd/xui-agent-windows), serviced by
|
||||
// re-running install-agent.ps1: the release assets an intent points
|
||||
// at are the panel's Linux binaries, so an intent delivered to a
|
||||
// Windows node is acknowledged and skipped, never downloaded.
|
||||
// A package variable so tests can exercise the skip on any OS.
|
||||
var selfUpdateSupported = runtime.GOOS != "windows"
|
||||
|
||||
func (a *Agent) setUpdateReport(st agentUpdateStatus) {
|
||||
a.updateMu.Lock()
|
||||
cp := st
|
||||
@@ -82,6 +91,19 @@ func (a *Agent) maybeHandleUpdate(intent *updateIntent) {
|
||||
a.updateMu.Unlock()
|
||||
return
|
||||
}
|
||||
if !selfUpdateSupported {
|
||||
// Windows (see selfUpdateSupported): log once per intent and
|
||||
// mark it handled so the poll does not re-log it every cycle.
|
||||
// No report is filed — the master-side intent simply ages out,
|
||||
// and the node updates by re-running install-agent.ps1.
|
||||
a.updateMu.Lock()
|
||||
if intent.RequestedAt != a.updateHandled {
|
||||
a.updateHandled = intent.RequestedAt
|
||||
logger.Infof("agent update to %s offered but skipped on %s: Windows agents update by re-running install-agent.ps1", intent.Target, runtime.GOOS)
|
||||
}
|
||||
a.updateMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.updateMu.Lock()
|
||||
if a.updateRunning || intent.RequestedAt == a.updateHandled ||
|
||||
(intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) {
|
||||
|
||||
@@ -159,6 +159,39 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpdateSkippedOnUnsupportedOS(t *testing.T) {
|
||||
orig := selfUpdateSupported
|
||||
selfUpdateSupported = false
|
||||
t.Cleanup(func() { selfUpdateSupported = orig })
|
||||
|
||||
var hits atomic.Int32
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
|
||||
intent := &updateIntent{RequestedAt: 46, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"}
|
||||
a.maybeHandleUpdate(intent)
|
||||
// A repeated delivery of the same intent is also a no-op.
|
||||
a.maybeHandleUpdate(intent)
|
||||
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("release server hit %d times on an unsupported OS, want 0", hits.Load())
|
||||
}
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("exe = %q, want untouched old-binary", got)
|
||||
}
|
||||
a.updateMu.Lock()
|
||||
handled, running := a.updateHandled, a.updateRunning
|
||||
a.updateMu.Unlock()
|
||||
if handled != 46 || running {
|
||||
t.Fatalf("handled=%d running=%v, want handled=46 running=false", handled, running)
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep != nil {
|
||||
t.Fatalf("report = %+v, want none for a skipped intent", rep)
|
||||
}
|
||||
}
|
||||
|
||||
var errExecBoom = &execBoomError{}
|
||||
|
||||
type execBoomError struct{}
|
||||
|
||||
@@ -125,16 +125,30 @@ func GetDisplayVersion() string {
|
||||
return defaultDisplayVersion
|
||||
}
|
||||
|
||||
// bareDisplayVersion is GetDisplayVersion without a single leading
|
||||
// V/v. Release builds inject the number either bare ("2.1.0") or in
|
||||
// the master's V-form ("V2.1.0"); the letter-prefixed getters below
|
||||
// must compose from the bare form so the letter never doubles
|
||||
// ("VV2.1.0" / "AV2.1.0" — the latter is what agent rows showed when
|
||||
// the V-form was injected).
|
||||
func bareDisplayVersion() string {
|
||||
v := GetDisplayVersion()
|
||||
if len(v) > 1 && (v[0] == 'V' || v[0] == 'v') {
|
||||
return v[1:]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetMasterDisplayVersion is the master's display version ("V1.0.0"),
|
||||
// shown in the panel's version slot.
|
||||
func GetMasterDisplayVersion() string {
|
||||
return "V" + GetDisplayVersion()
|
||||
return "V" + bareDisplayVersion()
|
||||
}
|
||||
|
||||
// GetAgentDisplayVersion is the agent's display version ("A1.0.0"),
|
||||
// reported in agent telemetry and shown on agent node rows.
|
||||
func GetAgentDisplayVersion() string {
|
||||
return "A" + GetDisplayVersion()
|
||||
return "A" + bareDisplayVersion()
|
||||
}
|
||||
|
||||
// ParseDisplayVersion parses a display version into its three numeric
|
||||
|
||||
@@ -75,6 +75,20 @@ func TestDisplayVersionDefaultsAndOverride(t *testing.T) {
|
||||
if got := GetAgentDisplayVersion(); got != "A1.0.1" {
|
||||
t.Fatalf("injected agent display version = %q, want A1.0.1", got)
|
||||
}
|
||||
|
||||
// A release build may inject the number in the master's V-form:
|
||||
// neither getter may double its letter ("VV1.0.2" / "AV1.0.2").
|
||||
displayVersion = "V1.0.2"
|
||||
if got := GetMasterDisplayVersion(); got != "V1.0.2" {
|
||||
t.Fatalf("V-injected master display version = %q, want V1.0.2", got)
|
||||
}
|
||||
if got := GetAgentDisplayVersion(); got != "A1.0.2" {
|
||||
t.Fatalf("V-injected agent display version = %q, want A1.0.2", got)
|
||||
}
|
||||
displayVersion = "v1.0.3"
|
||||
if got := GetAgentDisplayVersion(); got != "A1.0.3" {
|
||||
t.Fatalf("v-injected agent display version = %q, want A1.0.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDisplayVersion(t *testing.T) {
|
||||
|
||||
@@ -45,6 +45,11 @@ type AgentNode struct {
|
||||
// TelemetryAt == 0 and the UI keeps showing "-" for its row.
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
XrayVersion string `json:"xrayVersion"`
|
||||
// SingboxVersion is the agent's sing-box core version (V2.0.1 ②):
|
||||
// reported on the same telemetry since ITEM6 B3, persisted only
|
||||
// from V2.0.1 on — the node list shows it in place of the xray
|
||||
// version for agents that report one.
|
||||
SingboxVersion string `json:"singboxVersion"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||
LatencyMs int `json:"latencyMs"`
|
||||
CpuPct float64 `json:"cpuPct"`
|
||||
@@ -103,6 +108,13 @@ type AgentNode struct {
|
||||
SeenIPv6 string `json:"seenIpv6"`
|
||||
SeenIPAt int64 `json:"seenIpAt"`
|
||||
|
||||
// ProbeOnly marks the node as probe-only (V2.1.0): the master serves
|
||||
// no core config for it and the agent runs no core at all — only the
|
||||
// probe channels (heartbeat, fast report, latency tasks) stay live.
|
||||
// Set from the panel's node form; the agent can also preset it in
|
||||
// its local agent.json, either source turns the mode on.
|
||||
ProbeOnly bool `json:"probeOnly"`
|
||||
|
||||
// One-click update (item 20): the master records an update intent here;
|
||||
// the agent picks it up from its config poll, downloads + verifies +
|
||||
// swaps its binary, restarts itself and reports the outcome back on
|
||||
|
||||
@@ -1 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:21443?encryption=none&flow=xtls-rprx-vision&fm=%7B%22tcp%22%3A%5B%7B%22settings%22%3A%7B%22interval%22%3A%2210-20%22%2C%22length%22%3A%22100-200%22%2C%22packets%22%3A%22tlshello%22%7D%2C%22type%22%3A%22fragment%22%7D%5D%7D&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-finalmask-user%40example.com
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:21443?encryption=none&flow=xtls-rprx-vision&fm=%7B%22tcp%22%3A%5B%7B%22settings%22%3A%7B%22interval%22%3A%2210-20%22%2C%22length%22%3A%22100-200%22%2C%22packets%22%3A%22tlshello%22%7D%2C%22type%22%3A%22fragment%22%7D%5D%7D&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&type=tcp#fx-vless-finalmask-user%40example.com
|
||||
+1
-1
@@ -1 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:20443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=WGmv9FBUlzLLqu1eXfmzCm2jHLDldCutWtShp2jxpns%3D&security=reality&sid=77aa10f0&sni=www.microsoft.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-fallback-user%40example.com
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:20443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=WGmv9FBUlzLLqu1eXfmzCm2jHLDldCutWtShp2jxpns%3D&security=reality&sid=77aa10f0&sni=www.microsoft.com&type=tcp#fx-vless-fallback-user%40example.com
|
||||
@@ -1 +1 @@
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&spx=%2Fd0cf3c252cd6df6&type=tcp#fx-vless-reality-user%40example.com
|
||||
vless://11111111-2222-4333-8444-555555555555@203.0.113.10:443?encryption=none&flow=xtls-rprx-vision&fp=chrome&pbk=B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9%2FAsrhtHHw%3D&security=reality&sid=ab12cd34&sni=www.amd.com&type=tcp#fx-vless-reality-user%40example.com
|
||||
+41
-27
@@ -957,7 +957,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
case "tls":
|
||||
applyShareTLSParams(stream, params)
|
||||
case "reality":
|
||||
applyShareRealityParams(stream, params, subKey(client))
|
||||
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
|
||||
default:
|
||||
params["security"] = "none"
|
||||
}
|
||||
@@ -1010,7 +1010,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
case "tls":
|
||||
applyShareTLSParams(stream, params)
|
||||
case "reality":
|
||||
applyShareRealityParams(stream, params, subKey(client))
|
||||
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
|
||||
if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow {
|
||||
params["flow"] = client.Flow
|
||||
}
|
||||
@@ -1700,7 +1700,30 @@ func hysteriaPinHex(pin string) string {
|
||||
return pin
|
||||
}
|
||||
|
||||
func applyShareRealityParams(stream map[string]any, params map[string]string, clientKey string) {
|
||||
// singboxFragmentServerName returns the tls.server_name of the
|
||||
// inbound's stored sing-box fragment — the ONE name the sing-box
|
||||
// REALITY server actually serves. The converter puts exactly one
|
||||
// name there (realitySettings.serverNames[0], see internal/singbox)
|
||||
// while the legacy settings may list dozens. "" when the inbound has
|
||||
// no fragment, the fragment is unparseable, or it carries no server
|
||||
// name; callers then keep the historical behaviour.
|
||||
func singboxFragmentServerName(singboxConfig string) string {
|
||||
if strings.TrimSpace(singboxConfig) == "" {
|
||||
return ""
|
||||
}
|
||||
var fragment map[string]any
|
||||
if err := json.Unmarshal([]byte(singboxConfig), &fragment); err != nil {
|
||||
return ""
|
||||
}
|
||||
if tlsBlock, ok := fragment["tls"].(map[string]any); ok {
|
||||
if name, ok := tlsBlock["server_name"].(string); ok {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyShareRealityParams(stream map[string]any, fragmentSNI string, params map[string]string) {
|
||||
params["security"] = "reality"
|
||||
realitySetting, _ := stream["realitySettings"].(map[string]any)
|
||||
realitySettings, _ := searchKey(realitySetting, "settings")
|
||||
@@ -1710,6 +1733,15 @@ func applyShareRealityParams(stream map[string]any, params map[string]string, cl
|
||||
params["sni"], _ = sNames[random.Num(len(sNames))].(string)
|
||||
}
|
||||
}
|
||||
// The fragment's server_name wins over the pick above: xray
|
||||
// serves the whole serverNames list, so a random entry was
|
||||
// safe there, but the sing-box core serves only the
|
||||
// fragment's single name — any other entry is refused as an
|
||||
// invalid REALITY connection (the PPF-L3 outage: 29 listed
|
||||
// names, core serving only the first).
|
||||
if fragmentSNI != "" {
|
||||
params["sni"] = fragmentSNI
|
||||
}
|
||||
if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok {
|
||||
params["pbk"], _ = pbkValue.(string)
|
||||
}
|
||||
@@ -1728,31 +1760,13 @@ func applyShareRealityParams(stream map[string]any, params map[string]string, cl
|
||||
params["pqv"] = pqv
|
||||
}
|
||||
}
|
||||
seed := ""
|
||||
if spxValue, ok := searchKey(realitySettings, "spiderX"); ok {
|
||||
seed, _ = spxValue.(string)
|
||||
// V2.0.1 ⑦: no spx parameter is emitted any more. The
|
||||
// per-client spiderX derivation produced values some clients
|
||||
// refuse to store (the operator's client errors out on the
|
||||
// unknown parameter), and REALITY does not need it — the
|
||||
// crawl path is a client-side nicety, not part of the
|
||||
// handshake contract.
|
||||
}
|
||||
params["spx"] = deriveSpiderX(seed, clientKey)
|
||||
}
|
||||
}
|
||||
|
||||
// subKey returns a stable per-client identity for deterministic derivations,
|
||||
// preferring the subscription id and falling back to the (unique) email.
|
||||
func subKey(c model.Client) string {
|
||||
if c.SubID != "" {
|
||||
return c.SubID
|
||||
}
|
||||
return c.Email
|
||||
}
|
||||
|
||||
// deriveSpiderX maps the inbound's spiderX seed plus a stable client key to a
|
||||
// deterministic per-client "/path"; frontend/src/lib/xray/spider-x.ts mirrors it.
|
||||
func deriveSpiderX(seed, clientKey string) string {
|
||||
if seed == "" && clientKey == "" {
|
||||
return "/" + random.Seq(15)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(seed + "|" + clientKey))
|
||||
return "/" + hex.EncodeToString(sum[:])[:15]
|
||||
}
|
||||
|
||||
func buildVmessLink(obj map[string]any) string {
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
|
||||
}
|
||||
|
||||
// Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
|
||||
// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
|
||||
// catch a swap mutant. spx must never be emitted (V2.0.1 ⑦).
|
||||
func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"reality",
|
||||
@@ -74,21 +74,77 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
"pbk=PBKvalue",
|
||||
"sid=ab12cd",
|
||||
"fp=firefox",
|
||||
"spx=%2F",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(link, w) {
|
||||
t.Fatalf("reality link missing %q\n got: %s", w, link)
|
||||
}
|
||||
}
|
||||
if strings.Contains(link, "spx") {
|
||||
t.Fatalf("reality link must not carry spx (V2.0.1 ⑦): %s", link)
|
||||
}
|
||||
// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
|
||||
if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
|
||||
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
|
||||
}
|
||||
}
|
||||
|
||||
// Scope D: with several serverNames, the generated link's sni must be
|
||||
// the sing-box fragment's tls.server_name — the only name the sing-box
|
||||
// REALITY server serves (the converter stores serverNames[0] there).
|
||||
// The historical behaviour picked a uniformly random list entry per
|
||||
// generation, which xray tolerated (it serves the whole list) but
|
||||
// sing-box refuses as an invalid connection. Without a fragment the
|
||||
// sni still comes from the list.
|
||||
func TestGenVlessLink_RealitySniPrefersSingboxFragment(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"reality",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"realitySettings":{
|
||||
"serverNames":["amd.example.com","connect-sit.amd.example.com","www.amd.example.com"],
|
||||
"shortIds":["ab12cd"],
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
|
||||
}
|
||||
}`
|
||||
s := &SubService{}
|
||||
inbound := shareLinkInbound(stream)
|
||||
inbound.SingboxConfig = `{"type":"vless","tag":"in","listen":"::","listen_port":443,` +
|
||||
`"tls":{"enabled":true,"server_name":"amd.example.com",` +
|
||||
`"reality":{"enabled":true,"short_id":["ab12cd"]}}}`
|
||||
|
||||
// The losing branch is a random pick: sample enough generations
|
||||
// that a regression to it cannot survive by luck (2/3 wrong per
|
||||
// draw; 30 draws make a false pass ~2e-6).
|
||||
for i := 0; i < 30; i++ {
|
||||
link := s.genVlessLink(inbound, "user")
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
if got := u.Query().Get("sni"); got != "amd.example.com" {
|
||||
t.Fatalf("draw %d: sni = %q, want the fragment's amd.example.com\nlink: %s", i, got, link)
|
||||
}
|
||||
}
|
||||
|
||||
// No fragment (or an unusable one): historical behaviour — the
|
||||
// sni is some entry of the serverNames list.
|
||||
for _, frag := range []string{"", "{not json", `{"type":"vless"}`} {
|
||||
inbound.SingboxConfig = frag
|
||||
link := s.genVlessLink(inbound, "user")
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
switch got := u.Query().Get("sni"); got {
|
||||
case "amd.example.com", "connect-sit.amd.example.com", "www.amd.example.com":
|
||||
default:
|
||||
t.Fatalf("fragment %q: sni = %q, want a serverNames entry", frag, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
|
||||
// with distinct subIds so the per-client spx derivation can be exercised.
|
||||
// with distinct subIds.
|
||||
func realityTwoClientInbound() *model.Inbound {
|
||||
return &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
@@ -111,68 +167,19 @@ func realityTwoClientInbound() *model.Inbound {
|
||||
}
|
||||
}
|
||||
|
||||
func spxParam(t *testing.T, link string) string {
|
||||
t.Helper()
|
||||
// V2.0.1 ⑦: generated reality links never carry spx, for any client,
|
||||
// even when the inbound still stores a spiderX seed.
|
||||
func TestGenVlessLink_RealityOmitsSpx(t *testing.T) {
|
||||
s := &SubService{}
|
||||
inbound := realityTwoClientInbound()
|
||||
for _, email := range []string{"alice", "bob"} {
|
||||
link := s.genVlessLink(inbound, email)
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
spx := u.Query().Get("spx")
|
||||
if spx == "" || spx[0] != '/' {
|
||||
t.Fatalf("spx missing or not /-prefixed in %q", link)
|
||||
}
|
||||
return spx
|
||||
}
|
||||
|
||||
// spx must be stable for a given client across repeated exports (the #5718
|
||||
// complaint) yet differ between clients so the value can't be fingerprinted.
|
||||
func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
|
||||
s := &SubService{}
|
||||
inbound := realityTwoClientInbound()
|
||||
|
||||
aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
|
||||
aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
|
||||
bob := spxParam(t, s.genVlessLink(inbound, "bob"))
|
||||
|
||||
if aliceFirst != aliceSecond {
|
||||
t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
|
||||
}
|
||||
if aliceFirst == bob {
|
||||
t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
|
||||
if _, ok := u.Query()["spx"]; ok {
|
||||
t.Fatalf("link for %s carries spx: %s", email, link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveSpiderX(t *testing.T) {
|
||||
if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
|
||||
t.Fatalf("deriveSpiderX not deterministic: %q", got)
|
||||
}
|
||||
if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
|
||||
t.Fatal("deriveSpiderX must differ per client")
|
||||
}
|
||||
if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
|
||||
t.Fatal("rotating the seed must rotate a client's spx")
|
||||
}
|
||||
got := deriveSpiderX("seed", "clientA")
|
||||
if len(got) != 16 || got[0] != '/' {
|
||||
t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
|
||||
}
|
||||
if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
|
||||
t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
|
||||
// panel builds these links in TS, so both derivations must agree byte-for-byte.
|
||||
func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
|
||||
vectors := map[string]struct{ seed, clientKey, want string }{
|
||||
"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
|
||||
"seed only": {"/", "", "/d08ed99bd9afc60"},
|
||||
}
|
||||
for name, v := range vectors {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
|
||||
t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ func (a *AgentController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/heartbeat", a.heartbeat)
|
||||
g.GET("/config", a.getConfig)
|
||||
g.POST("/traffic", a.reportTraffic)
|
||||
// V2.0.1 probe batch: the lightweight fast report (runtime
|
||||
// metrics only) that replaces the metrics half of the heartbeat
|
||||
// at the operator-configured cadence.
|
||||
g.POST("/probe-report", a.probeReport)
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes mounts the panel-authenticated administration endpoints
|
||||
@@ -217,6 +221,7 @@ func (a *AgentController) register(c *gin.Context) {
|
||||
type heartbeatTelemetry struct {
|
||||
AgentVersion *string `json:"agentVersion"`
|
||||
XrayVersion *string `json:"xrayVersion"`
|
||||
SingboxVersion *string `json:"singboxVersion"`
|
||||
UptimeSeconds *uint64 `json:"uptimeSeconds"`
|
||||
LatencyMs *int `json:"latencyMs"`
|
||||
CpuPercent *float64 `json:"cpuPercent"`
|
||||
@@ -235,6 +240,9 @@ func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) {
|
||||
if t.XrayVersion != nil {
|
||||
updates["xray_version"] = *t.XrayVersion
|
||||
}
|
||||
if t.SingboxVersion != nil {
|
||||
updates["singbox_version"] = *t.SingboxVersion
|
||||
}
|
||||
if t.UptimeSeconds != nil {
|
||||
updates["uptime_secs"] = *t.UptimeSeconds
|
||||
}
|
||||
@@ -605,6 +613,53 @@ func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
// hard 500: the agent keeps its current runtime state and retries on
|
||||
// the next poll, and the failure stays visible in both logs instead
|
||||
// of silently freezing the node's config.
|
||||
// probeStatIface reads the agent's counted-interface pin from its
|
||||
// probe metadata ("" = automatic selection, and the answer for any
|
||||
// read failure — a config poll must not fail over a display-side
|
||||
// setting).
|
||||
func probeStatIface(agentID int64) string {
|
||||
meta, found, err := probe.MetaFor(agentID)
|
||||
if err != nil || !found {
|
||||
return ""
|
||||
}
|
||||
return meta.StatIface
|
||||
}
|
||||
|
||||
// probeReport ingests one fast report (V2.0.1): a probe metrics
|
||||
// block and nothing else. Since V2.0.3 it also touches the agent's
|
||||
// last-seen stamp, like every other report channel: the hub's
|
||||
// themes gate each batch on the node's last_seen being newer than
|
||||
// the last one they applied, so a metrics-only channel that never
|
||||
// advanced the stamp froze the public cards between the 10 s
|
||||
// traffic reports. Liveness now means "any report from the agent"
|
||||
// — all channels come from the same process — and the traffic
|
||||
// ledger's unseen-boot rule is unaffected: Record still receives
|
||||
// the pre-touch stamp, so gaps measure exactly as before. Agents
|
||||
// on older masters never see this endpoint (404) and simply keep
|
||||
// reporting metrics on the heartbeat; masters accept metrics on
|
||||
// either channel.
|
||||
func (a *AgentController) probeReport(c *gin.Context) {
|
||||
agent, ok := a.findAgent(bearerToken(c))
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
a.captureSeenIP(c, agent)
|
||||
var req struct {
|
||||
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
// The last-seen stamp as it stood before this report (see the
|
||||
// heartbeat handler): captured before touchAgent overwrites it.
|
||||
prevLastSeen := agent.LastSeen
|
||||
a.touchAgent(uint(agent.Id), "")
|
||||
if req.ProbeMetrics != nil {
|
||||
a.updateAgentProbe(uint(agent.Id), nil, req.ProbeMetrics)
|
||||
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
|
||||
}
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
|
||||
func (a *AgentController) getConfig(c *gin.Context) {
|
||||
agent, ok := a.findAgent(bearerToken(c))
|
||||
if !ok {
|
||||
@@ -616,28 +671,50 @@ func (a *AgentController) getConfig(c *gin.Context) {
|
||||
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
|
||||
return
|
||||
}
|
||||
// Probe-only nodes (V2.1.0) get no core payload at all: the build
|
||||
// is skipped entirely, so a broken data model on such a node can
|
||||
// never 500 its poll, and the probe keys + update intent below
|
||||
// ride along exactly as for normal nodes. singbox_config stays
|
||||
// null and the hash empty, which agents read as "no payload".
|
||||
var sbRaw json.RawMessage
|
||||
sbHash := ""
|
||||
if !agent.ProbeOnly {
|
||||
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
|
||||
if sbErr != nil {
|
||||
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
|
||||
c.JSON(500, gin.H{"message": "failed to build sing-box config"})
|
||||
return
|
||||
}
|
||||
sbRaw, mErr := json.Marshal(sbCfg)
|
||||
raw, mErr := json.Marshal(sbCfg)
|
||||
if mErr != nil {
|
||||
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
|
||||
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
|
||||
return
|
||||
}
|
||||
sbRaw = raw
|
||||
sbSum := sha256.Sum256(sbRaw)
|
||||
sbHash = hex.EncodeToString(sbSum[:])
|
||||
}
|
||||
payload := gin.H{
|
||||
"singbox_config": json.RawMessage(sbRaw),
|
||||
"singbox_config_hash": hex.EncodeToString(sbSum[:]),
|
||||
"singbox_config": sbRaw,
|
||||
"singbox_config_hash": sbHash,
|
||||
// Probe-only (V2.1.0): the per-node flag rides every poll so
|
||||
// toggling it in the panel takes effect without touching the
|
||||
// node. Always present from this master version.
|
||||
"probe_only": agent.ProbeOnly,
|
||||
// Item 7 B4: the agent's probe task set rides the existing
|
||||
// config poll (no new channel). Always present from this
|
||||
// master version — an empty list means "run no probes", while
|
||||
// an absent field (older master) leaves the agent's current
|
||||
// set untouched.
|
||||
"probe_tasks": probe.PingTasksFor(int64(agent.Id)),
|
||||
// V2.0.1 probe batch: the fast-report cadence and this node's
|
||||
// counted-interface pin ride the same poll. Older agents
|
||||
// ignore both keys and keep the pre-V2.0.1 behaviour
|
||||
// (metrics on the 60 s heartbeat, automatic interface
|
||||
// selection).
|
||||
"probe_fast_interval": service.ProbeFastIntervalSec(),
|
||||
"probe_stat_iface": probeStatIface(int64(agent.Id)),
|
||||
}
|
||||
// Item 20: a pending one-click update intent rides along on the config
|
||||
// poll (no new channel — the agent already polls this endpoint).
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
)
|
||||
|
||||
// V2.0.1 probe batch, master side: the fast-report endpoint ingests
|
||||
// metrics, and the config poll carries the cadence + the node's
|
||||
// counted-interface pin. Since V2.0.3 the fast report also stamps
|
||||
// last-seen (the hub's themes gate batches on its freshness); it
|
||||
// originally did not — see the probeReport handler comment.
|
||||
func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
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()
|
||||
|
||||
const token = "fast-report-token"
|
||||
node := &model.Node{Name: "fast-node", Address: "192.0.2.1", Port: 8443, Enable: true}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
agent := &model.AgentNode{TokenHash: hashAgentToken(token), TokenPrefix: "fast-", Name: "fast-node", NodeID: &node.Id}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
NewAgentController(engine.Group("/panel/api/agent"))
|
||||
|
||||
call := func(method, path, body string) (int, []byte) {
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.Bytes()
|
||||
}
|
||||
load := func() model.AgentNode {
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, agent.Id).Error; err != nil {
|
||||
t.Fatalf("reload agent: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// One heartbeat establishes last-seen.
|
||||
if code, _ := call(http.MethodPost, "/panel/api/agent/heartbeat", `{"token":"`+token+`","timestamp":1}`); code != http.StatusOK {
|
||||
t.Fatalf("heartbeat: %d", code)
|
||||
}
|
||||
seenAfterHeartbeat := load().LastSeen
|
||||
if seenAfterHeartbeat == 0 {
|
||||
t.Fatal("heartbeat did not stamp last-seen")
|
||||
}
|
||||
|
||||
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
|
||||
`"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}`
|
||||
// Age the stamp first so the fast report's touch is observable
|
||||
// even within the same millisecond as the heartbeat's.
|
||||
stale := seenAfterHeartbeat - 120_000
|
||||
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil {
|
||||
t.Fatalf("age last-seen: %v", err)
|
||||
}
|
||||
if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK {
|
||||
t.Fatalf("probe-report: %d (%s)", code, body)
|
||||
}
|
||||
row := load()
|
||||
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
|
||||
t.Errorf("fast report metrics not stored: %+v", row)
|
||||
}
|
||||
if row.LastSeen <= stale {
|
||||
t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, row.LastSeen)
|
||||
}
|
||||
|
||||
// Unauthorized callers are refused.
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/probe-report", bytes.NewBufferString(metrics))
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("probe-report with bad token: %d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
configObj := func() map[string]any {
|
||||
code, body := call(http.MethodGet, "/panel/api/agent/config", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("config poll: %d (%s)", code, body)
|
||||
}
|
||||
// getConfig answers with the bare payload (no envelope).
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(body, &obj); err != nil {
|
||||
t.Fatalf("config payload: %v", err)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
obj := configObj()
|
||||
if obj["probe_fast_interval"] != float64(3) {
|
||||
t.Errorf("default probe_fast_interval = %v, want 3", obj["probe_fast_interval"])
|
||||
}
|
||||
if obj["probe_stat_iface"] != "" {
|
||||
t.Errorf("default probe_stat_iface = %v, want empty", obj["probe_stat_iface"])
|
||||
}
|
||||
|
||||
// Operator changes: cadence via the probe settings block, pin
|
||||
// via the node's probe meta — both must ride the next poll.
|
||||
settings := service.GetProbePublicSettings()
|
||||
settings.FastIntervalSec = 9
|
||||
if err := service.SetProbePublicSettings(settings); err != nil {
|
||||
t.Fatalf("SetProbePublicSettings: %v", err)
|
||||
}
|
||||
st := probe.Default()
|
||||
meta := probe.DefaultMeta(int64(agent.Id))
|
||||
meta.StatIface = "eth9"
|
||||
if err := st.UpsertMeta(meta); err != nil {
|
||||
t.Fatalf("UpsertMeta: %v", err)
|
||||
}
|
||||
obj = configObj()
|
||||
if obj["probe_fast_interval"] != float64(9) {
|
||||
t.Errorf("probe_fast_interval = %v, want 9", obj["probe_fast_interval"])
|
||||
}
|
||||
if obj["probe_stat_iface"] != "eth9" {
|
||||
t.Errorf("probe_stat_iface = %v, want eth9", obj["probe_stat_iface"])
|
||||
}
|
||||
|
||||
// Settings validation clamps the ranges the page offers.
|
||||
bad := service.GetProbePublicSettings()
|
||||
bad.RetentionDays = 0
|
||||
if err := service.SetProbePublicSettings(bad); err == nil {
|
||||
t.Error("retention_days = 0 accepted, want refusal")
|
||||
}
|
||||
bad = service.GetProbePublicSettings()
|
||||
bad.FastIntervalSec = 61
|
||||
if err := service.SetProbePublicSettings(bad); err == nil {
|
||||
t.Error("fast interval = 61 accepted, want refusal")
|
||||
}
|
||||
}
|
||||
|
||||
// V2.0.1 ⑤: batch task creation — shared fields, auto names,
|
||||
// duplicate collapse, all-or-nothing validation.
|
||||
func TestProbeTasksBatch(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
cleanPingTasks(t)
|
||||
const batchPath = "/panel/api/agent/probe-tasks/batch"
|
||||
|
||||
rec := rig.do(http.MethodPost, batchPath, []byte(
|
||||
`{"targets":["1.1.1.1:443","8.8.8.8:53"," 1.1.1.1:443 "],"autoJoin":true}`), true)
|
||||
env := decodeEnvelope(t, rec.Body.Bytes())
|
||||
if !env.Success {
|
||||
t.Fatalf("batch create refused: %s", env.Msg)
|
||||
}
|
||||
var obj struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Obj, &obj); err != nil || len(obj.IDs) != 2 {
|
||||
t.Fatalf("batch ids = %s (err %v), want 2 ids (duplicate collapsed)", env.Obj, err)
|
||||
}
|
||||
tasks, err := probe.PingTasks()
|
||||
if err != nil || len(tasks) != 2 {
|
||||
t.Fatalf("tasks after batch = %d (err %v)", len(tasks), err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.Name != task.Target {
|
||||
t.Errorf("auto name = %q, want the target %q", task.Name, task.Target)
|
||||
}
|
||||
if task.Interval != 60 {
|
||||
t.Errorf("default interval = %d, want 60", task.Interval)
|
||||
}
|
||||
if !task.AutoJoin {
|
||||
t.Errorf("autoJoin not carried into task %d", task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
cleanPingTasks(t)
|
||||
rec = rig.do(http.MethodPost, batchPath, []byte(
|
||||
`{"targets":["1.1.1.1:443","8.8.8.8:53"],"name":"线路","interval":30}`), true)
|
||||
env = decodeEnvelope(t, rec.Body.Bytes())
|
||||
if !env.Success {
|
||||
t.Fatalf("named batch refused: %s", env.Msg)
|
||||
}
|
||||
tasks, _ = probe.PingTasks()
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("named batch tasks = %d", len(tasks))
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if !strings.HasPrefix(task.Name, "线路 · ") || task.Interval != 30 {
|
||||
t.Errorf("named batch task = %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
// One bad line refuses the whole paste; nothing is created.
|
||||
cleanPingTasks(t)
|
||||
rec = rig.do(http.MethodPost, batchPath, []byte(
|
||||
`{"targets":["1.1.1.1:443","not-a-target"]}`), true)
|
||||
env = decodeEnvelope(t, rec.Body.Bytes())
|
||||
if env.Success {
|
||||
t.Fatal("batch with a bad target succeeded")
|
||||
}
|
||||
if tasks, _ := probe.PingTasks(); len(tasks) != 0 {
|
||||
t.Fatalf("refused batch leaked %d tasks", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// V2.0.1 probe batch: the meta editor's calibration + interface pin
|
||||
// fields round-trip and validate.
|
||||
func TestProbeMetaCalibrationFields(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
db := database.GetDB()
|
||||
nodeID := 77
|
||||
node := &model.Node{Id: nodeID, Name: "meta-node", Address: "192.0.2.1", Port: 8443, Enable: true}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
agent := &model.AgentNode{TokenHash: hashAgentToken("meta-token"), TokenPrefix: "meta-", Name: "meta-agent", NodeID: &nodeID}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
path := "/panel/api/agent/probe-meta/77"
|
||||
|
||||
put := func(body string) envelope {
|
||||
rec := rig.do(http.MethodPut, path, []byte(body), true)
|
||||
return decodeEnvelope(t, rec.Body.Bytes())
|
||||
}
|
||||
base := `"price":0,"currency":"USD","billingCycle":"monthly",` +
|
||||
`"expiresAt":null,"trafficLimit":0,"trafficMode":"sum","trafficResetDay":1,"public":true,"sort":0,` +
|
||||
`"group":"","publicRemark":"","notify":false,"countryPin":"","ipv4Pin":"","ipv6Pin":""`
|
||||
|
||||
env := put(`{` + base + `,"trafficCalUpGb":2.5,"trafficCalDownGb":-1,"statIface":" eth0 "}`)
|
||||
if !env.Success {
|
||||
t.Fatalf("meta save with calibration refused: %s", env.Msg)
|
||||
}
|
||||
meta, found, err := probe.MetaFor(int64(agent.Id))
|
||||
if err != nil || !found {
|
||||
t.Fatalf("MetaFor: found=%v err=%v", found, err)
|
||||
}
|
||||
if meta.TrafficCalUpGB != 2.5 || meta.TrafficCalDownGB != -1 || meta.StatIface != "eth0" {
|
||||
t.Errorf("stored meta = up %v down %v iface %q (pin must be trimmed)", meta.TrafficCalUpGB, meta.TrafficCalDownGB, meta.StatIface)
|
||||
}
|
||||
|
||||
env = put(`{` + base + `,"statIface":"eth 0"}`)
|
||||
if env.Success {
|
||||
t.Error("interface name with a space accepted")
|
||||
}
|
||||
env = put(`{` + base + `,"statIface":"eth/0"}`)
|
||||
if env.Success {
|
||||
t.Error("interface name with a slash accepted")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
// group itself (/panel/api/probe/*).
|
||||
NewProbeThemesController(api)
|
||||
|
||||
// Item 7 B6: notification settings + test send + cf-probe
|
||||
// retirement coverage, same group (/panel/api/probe/notify*).
|
||||
// Item 7 B6: notification settings + test send, same group
|
||||
// (/panel/api/probe/notify*).
|
||||
NewProbeNotifyController(api)
|
||||
|
||||
// Hosts API — per-inbound override endpoints for subscription links
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
@@ -167,11 +170,25 @@ func (a *NodeController) add(c *gin.Context) {
|
||||
if isAgent {
|
||||
view.NodeType = "agent"
|
||||
nodeID := view.Id
|
||||
token, _, err := issueAgentToken(&nodeID, n.Name)
|
||||
token, agentID, err := issueAgentToken(&nodeID, n.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
// Probe-only (V2.1.0): preset the flag on the freshly issued
|
||||
// agent row when the create form asked for it, so the very
|
||||
// first config poll already serves no core payload. A failed
|
||||
// preset only logs — the node and token are usable, and the
|
||||
// flag can still be flipped from the edit form.
|
||||
if n.ProbeOnly != nil && *n.ProbeOnly {
|
||||
if err := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("id = ?", agentID).
|
||||
Update("probe_only", true).Error; err != nil {
|
||||
logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err)
|
||||
} else {
|
||||
view.ProbeOnly = true
|
||||
}
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
||||
"node": view,
|
||||
"agentToken": token,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// V2.0.1 ③: with the public page off, the root path and theme paths
|
||||
// answer a plain 404 to anonymous visitors instead of the theme
|
||||
// shell (whose "public page off" redirect chain dead-ends on
|
||||
// /admin). A logged-in operator still reaches the theme, and with
|
||||
// the page on the shell is public again.
|
||||
func TestProbeFallbackPublicPageGate(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
|
||||
themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist")
|
||||
if err := os.MkdirAll(themeDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"name":"Test Theme","short":"testtheme","description":"gate test fixture","version":"0.0.1","author":"tests","url":""}`
|
||||
if err := os.WriteFile(filepath.Join(rig.dbDir, "themes", "testtheme", "theme.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexHTML := "<!doctype html><title>theme</title>"
|
||||
if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rig.setSetting(t, "theme", "testtheme")
|
||||
|
||||
// Public page off (the default): anonymous root + SPA paths 404.
|
||||
if rec := rig.do(http.MethodGet, "/", nil, false); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("anon root with public page off: %d, want 404", rec.Code)
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/some/spa/route", nil, false); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("anon spa path with public page off: %d, want 404", rec.Code)
|
||||
}
|
||||
// API 404 discipline is unchanged.
|
||||
if rec := rig.do(http.MethodGet, "/api/nope", nil, false); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("api 404 with public page off: %d", rec.Code)
|
||||
}
|
||||
// Logged-in operator still reaches the theme shell.
|
||||
if rec := rig.do(http.MethodGet, "/", nil, true); rec.Code != 200 || rec.Body.String() != indexHTML {
|
||||
t.Fatalf("authed root with public page off: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Public page on: anonymous shell access is back.
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
if rec := rig.do(http.MethodGet, "/", nil, false); rec.Code != 200 || rec.Body.String() != indexHTML {
|
||||
t.Fatalf("anon root with public page on: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,14 @@ type probeMetaForm struct {
|
||||
CountryPin string `json:"countryPin"`
|
||||
IPv4Pin string `json:"ipv4Pin"`
|
||||
IPv6Pin string `json:"ipv6Pin"`
|
||||
// V2.0.1: traffic calibration (GB, either sign) and the counted
|
||||
// interface pin ("" = automatic). Since V2.2.1 ⑥b the calibration
|
||||
// fields are ONE-SHOT deltas: a non-zero value is folded into the
|
||||
// stored standing correction on save (see probe.MergeCalDelta);
|
||||
// zero/absent applies nothing.
|
||||
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
|
||||
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
|
||||
StatIface string `json:"statIface"`
|
||||
}
|
||||
|
||||
func metaToForm(m probe.Meta) probeMetaForm {
|
||||
@@ -70,6 +78,12 @@ func metaToForm(m probe.Meta) probeMetaForm {
|
||||
CountryPin: m.CountryPin,
|
||||
IPv4Pin: m.IPv4Pin,
|
||||
IPv6Pin: m.IPv6Pin,
|
||||
// One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
|
||||
// boxes always start empty; the standing correction folded
|
||||
// into the stored row is never echoed back into them.
|
||||
TrafficCalUpGB: 0,
|
||||
TrafficCalDownGB: 0,
|
||||
StatIface: m.StatIface,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +105,9 @@ func (f probeMetaForm) toMeta(agentID int64) probe.Meta {
|
||||
CountryPin: f.CountryPin,
|
||||
IPv4Pin: f.IPv4Pin,
|
||||
IPv6Pin: f.IPv6Pin,
|
||||
TrafficCalUpGB: f.TrafficCalUpGB,
|
||||
TrafficCalDownGB: f.TrafficCalDownGB,
|
||||
StatIface: f.StatIface,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +156,16 @@ func (pc *ProbeMetaController) putMeta(c *gin.Context) {
|
||||
jsonMsg(c, msg, errRequest)
|
||||
return
|
||||
}
|
||||
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
|
||||
// Calibration is one-shot (V2.2.1 ⑥b): fold the submitted deltas
|
||||
// into the stored standing correction; all other fields replace.
|
||||
meta := form.toMeta(int64(agent.Id))
|
||||
if existing, found, err := probe.MetaFor(int64(agent.Id)); err != nil {
|
||||
jsonMsg(c, "save probe meta failed", err)
|
||||
return
|
||||
} else if found {
|
||||
meta = probe.MergeCalDelta(existing, meta)
|
||||
}
|
||||
if err := probe.SaveMeta(meta); err != nil {
|
||||
jsonMsg(c, "save probe meta failed", err)
|
||||
return
|
||||
}
|
||||
@@ -202,6 +228,14 @@ func validateProbeMetaForm(f *probeMetaForm) string {
|
||||
if math.IsNaN(f.Price) || math.IsInf(f.Price, 0) || f.Price < 0 || f.TrafficLimit < 0 {
|
||||
return "价格和流量上限不能是负数"
|
||||
}
|
||||
if math.IsNaN(f.TrafficCalUpGB) || math.IsInf(f.TrafficCalUpGB, 0) ||
|
||||
math.IsNaN(f.TrafficCalDownGB) || math.IsInf(f.TrafficCalDownGB, 0) {
|
||||
return "流量校正要填有限的数字(GB,可正可负)"
|
||||
}
|
||||
f.StatIface = strings.TrimSpace(f.StatIface)
|
||||
if len(f.StatIface) > 32 || hasControl(f.StatIface) || strings.ContainsAny(f.StatIface, " \t/") {
|
||||
return "统计网卡填网卡名(如 eth0),留空则自动甄别"
|
||||
}
|
||||
f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency))
|
||||
if len(f.Currency) != 3 || !isUpperASCII(f.Currency) {
|
||||
return "货币要填三个字母的代码,如 USD、CNY、HKD"
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probenotify"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -13,12 +12,12 @@ import (
|
||||
|
||||
// Item 7 B6: the notification settings surface — the notify_* block
|
||||
// (offline grace, traffic step, expiry window, login alerts, the
|
||||
// webhook channel and the two message templates), a test send, and
|
||||
// the cf-probe retirement coverage the operator checks before
|
||||
// standing the old probe fleet down. Like the rest of the probe
|
||||
// surfaces these endpoints answer in the panel's Msg envelope and
|
||||
// take effect immediately; validation refusals are operator-facing
|
||||
// text, shown verbatim.
|
||||
// webhook channel and the two message templates) and a test send.
|
||||
// Like the rest of the probe surfaces these endpoints answer in the
|
||||
// panel's Msg envelope and take effect immediately; validation
|
||||
// refusals are operator-facing text, shown verbatim. (The cf-probe
|
||||
// retirement coverage card that originally rode this payload was
|
||||
// removed in V2.0.1 ⑥ — the checklist lives in the operator docs.)
|
||||
|
||||
type ProbeNotifyController struct{}
|
||||
|
||||
@@ -32,13 +31,11 @@ func NewProbeNotifyController(g *gin.RouterGroup) *ProbeNotifyController {
|
||||
|
||||
type probeNotifyPayload struct {
|
||||
Settings probenotify.SettingsView `json:"settings"`
|
||||
Coverage []service.ProbeCoverageRow `json:"coverage"`
|
||||
}
|
||||
|
||||
func (pc *ProbeNotifyController) get(c *gin.Context) {
|
||||
jsonObj(c, probeNotifyPayload{
|
||||
Settings: probenotify.GetSettings(),
|
||||
Coverage: service.ProbeAgentCoverage(),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
@@ -59,7 +56,6 @@ func (pc *ProbeNotifyController) put(c *gin.Context) {
|
||||
// the channel status and secret-set flags from it.
|
||||
jsonObj(c, probeNotifyPayload{
|
||||
Settings: probenotify.GetSettings(),
|
||||
Coverage: service.ProbeAgentCoverage(),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -211,10 +211,12 @@ func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// liveWS is the hub's browser stream: a snapshot frame every 2 s,
|
||||
// text by default, binary gzip frames for the anonymous audience when
|
||||
// opened with ?gzip. Audience eligibility is re-checked every tick —
|
||||
// a socket outlives both the session and the public-page switch.
|
||||
// liveWS is the hub's browser stream: a snapshot frame on the
|
||||
// operator's fast-report cadence (V2.0.2; the hub's fixed 2 s tick
|
||||
// made a 1 s agent cadence invisible up front), text by default,
|
||||
// binary gzip frames for the anonymous audience when opened with
|
||||
// ?gzip. Audience eligibility is re-checked every tick — a socket
|
||||
// outlives both the session and the public-page switch.
|
||||
func (pc *PublicProbeController) liveWS(c *gin.Context) {
|
||||
authed := probeAuthed(c) && !fromOtherOrigin(c)
|
||||
if !authed && !service.ProbePublicPageEnabled() {
|
||||
@@ -280,18 +282,19 @@ func (pc *PublicProbeController) liveWS(c *gin.Context) {
|
||||
return conn.WriteMessage(frameType, frame) == nil
|
||||
}
|
||||
|
||||
// The first frame goes out immediately (hub's interval ticks at
|
||||
// once), then one per 2 s tick.
|
||||
// The first frame goes out immediately; later frames follow the
|
||||
// fast-report cadence, re-read after every frame so a settings
|
||||
// change lands on a live socket without a reconnect.
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
timer := time.NewTimer(time.Duration(service.ProbeFastIntervalSec()) * time.Second)
|
||||
select {
|
||||
case <-done:
|
||||
timer.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-timer.C:
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
@@ -378,5 +381,15 @@ func (pc *PublicProbeController) HandleFallback(c *gin.Context) bool {
|
||||
} else if strings.HasPrefix(path, "/panel") || strings.HasPrefix(path, "/assets") {
|
||||
return false
|
||||
}
|
||||
// V2.0.1 ③: with the public page switched off, the theme shell
|
||||
// is not public content — an anonymous visitor gets a plain 404
|
||||
// (the pre-item-7 behaviour for the root path), never the shell
|
||||
// whose "public page off" redirect chain dead-ends on /admin.
|
||||
// A logged-in operator still reaches the theme (preview, theme
|
||||
// config edits), exactly like the data APIs' own gate.
|
||||
if !service.ProbePublicPageEnabled() && !probeAuthed(c) {
|
||||
answerText(c, http.StatusNotFound, "404 page not found")
|
||||
return true
|
||||
}
|
||||
return service.ServeProbeThemePath(c, strings.TrimPrefix(path, "/"))
|
||||
}
|
||||
|
||||
@@ -323,7 +323,9 @@ func TestProbePublicContract(t *testing.T) {
|
||||
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
|
||||
var node map[string]any
|
||||
_ = json.Unmarshal(nodeBody, &node)
|
||||
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
|
||||
// V2.2.1 ⑦: the public view's name is the public remark when one
|
||||
// is set (the seed sets "shown publicly"), not the node name.
|
||||
if node["id"] != float64(pubID) || node["name"] != "shown publicly" || node["online"] != true ||
|
||||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
|
||||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
|
||||
t.Fatalf("anon node values wrong: %v", node)
|
||||
|
||||
@@ -31,6 +31,7 @@ func NewProbeTasksController(g *gin.RouterGroup) *ProbeTasksController {
|
||||
pc := &ProbeTasksController{}
|
||||
g.GET("/probe-tasks", pc.listTasks)
|
||||
g.PUT("/probe-tasks", pc.saveTask)
|
||||
g.POST("/probe-tasks/batch", pc.saveTaskBatch)
|
||||
g.DELETE("/probe-tasks/:id", pc.deleteTask)
|
||||
g.POST("/probe-tasks/reorder", pc.reorderTasks)
|
||||
g.PUT("/probe-tasks/by-node/:nodeId", pc.setNodeTasks)
|
||||
@@ -269,6 +270,114 @@ func (pc *ProbeTasksController) saveTask(c *gin.Context) {
|
||||
jsonObj(c, gin.H{"id": id}, nil)
|
||||
}
|
||||
|
||||
// probeTaskBatchForm is the batch-create shape (V2.0.1 ⑤): many
|
||||
// targets at once — already split client-side from the pasted
|
||||
// lines/commas — sharing every other field. Name is a shared label:
|
||||
// with several targets each task is auto-named after its target
|
||||
// unless a name is given, in which case it prefixes the target
|
||||
// ("<name> · <target>"); with exactly one target the name is used
|
||||
// verbatim (and may be empty, falling back to the target).
|
||||
type probeTaskBatchForm struct {
|
||||
Targets []string `json:"targets"`
|
||||
Name string `json:"name"`
|
||||
Interval int64 `json:"interval"`
|
||||
AutoJoin bool `json:"autoJoin"`
|
||||
Region string `json:"region"`
|
||||
NodeIDs []int64 `json:"nodeIds"`
|
||||
}
|
||||
|
||||
// saveTaskBatch creates one task per target, all-or-nothing on
|
||||
// validation: every form is validated before the first insert, so a
|
||||
// bad line refuses the whole paste with the hub-style message
|
||||
// instead of leaving a half-entered list behind.
|
||||
func (pc *ProbeTasksController) saveTaskBatch(c *gin.Context) {
|
||||
var form probeTaskBatchForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
jsonMsg(c, "Invalid request", err)
|
||||
return
|
||||
}
|
||||
if form.NodeIDs == nil {
|
||||
form.NodeIDs = []int64{}
|
||||
}
|
||||
// The batch form's interval is a shared convenience field: an
|
||||
// omitted one means the standard 60 s (the single-task form
|
||||
// keeps requiring an explicit value).
|
||||
if form.Interval == 0 {
|
||||
form.Interval = 60
|
||||
}
|
||||
// Split remnants and duplicates in the paste collapse here;
|
||||
// order is preserved (it becomes the tasks' sort order).
|
||||
seen := map[string]bool{}
|
||||
targets := make([]string, 0, len(form.Targets))
|
||||
for _, raw := range form.Targets {
|
||||
tgt := strings.TrimSpace(raw)
|
||||
if tgt == "" || seen[tgt] {
|
||||
continue
|
||||
}
|
||||
seen[tgt] = true
|
||||
targets = append(targets, tgt)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
jsonMsg(c, "请填写目标", errRequest)
|
||||
return
|
||||
}
|
||||
sharedName := strings.TrimSpace(form.Name)
|
||||
forms := make([]probeTaskForm, 0, len(targets))
|
||||
for _, tgt := range targets {
|
||||
name := sharedName
|
||||
switch {
|
||||
case len(targets) == 1:
|
||||
if name == "" {
|
||||
name = tgt
|
||||
}
|
||||
case name == "":
|
||||
name = tgt
|
||||
default:
|
||||
name = name + " · " + tgt
|
||||
}
|
||||
f := probeTaskForm{
|
||||
Name: name,
|
||||
Target: tgt,
|
||||
Interval: form.Interval,
|
||||
AutoJoin: form.AutoJoin,
|
||||
Region: form.Region,
|
||||
NodeIDs: form.NodeIDs,
|
||||
}
|
||||
if msg := validateProbeTaskForm(&f); msg != "" {
|
||||
jsonMsg(c, msg, errRequest)
|
||||
return
|
||||
}
|
||||
forms = append(forms, f)
|
||||
}
|
||||
bound, err := agentsByNode()
|
||||
if err != nil {
|
||||
jsonMsg(c, "save probe tasks failed", err)
|
||||
return
|
||||
}
|
||||
agentIDs, refusal := resolveAgentIDs(form.NodeIDs, bound)
|
||||
if refusal != "" {
|
||||
jsonMsg(c, refusal, errRequest)
|
||||
return
|
||||
}
|
||||
ids := make([]int64, 0, len(forms))
|
||||
for _, f := range forms {
|
||||
id, err := probe.SavePingTask(probe.PingTask{
|
||||
Name: f.Name,
|
||||
Target: f.Target,
|
||||
Interval: f.Interval,
|
||||
AutoJoin: f.AutoJoin,
|
||||
Region: f.Region,
|
||||
AgentIDs: agentIDs,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
probeTaskError(c, err)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
jsonObj(c, gin.H{"ids": ids}, nil)
|
||||
}
|
||||
|
||||
func (pc *ProbeTasksController) deleteTask(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ws "github.com/gorilla/websocket"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// V2.0.2: /api/ws frame cadence follows the operator's fast-report
|
||||
// interval (probe_fast_interval) instead of the hub's fixed 2 s tick.
|
||||
// A mid-stream settings change re-paces a live socket: the timer
|
||||
// already in flight still fires on the old cadence, the next one is
|
||||
// armed from the re-read setting.
|
||||
func TestProbeWebSocketCadenceFollowsFastInterval(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
_, _ = rig.seedAgent(t, "CadenceNode", true)
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
rig.setSetting(t, "probe_fast_interval", "1")
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
srv := httptest.NewServer(rig.engine)
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
|
||||
|
||||
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
readAt := func() time.Time {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Fatalf("ws read: %v", err)
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
_ = readAt() // first frame: immediate
|
||||
prev := readAt()
|
||||
for i := 0; i < 2; i++ {
|
||||
now := readAt()
|
||||
if gap := now.Sub(prev); gap < 500*time.Millisecond || gap > 2500*time.Millisecond {
|
||||
t.Fatalf("frame gap at 1 s cadence: %v", gap)
|
||||
}
|
||||
prev = now
|
||||
}
|
||||
|
||||
// Change to 3 s mid-stream: one in-flight 1 s frame, then the
|
||||
// stream settles onto the new cadence without a reconnect.
|
||||
rig.setSetting(t, "probe_fast_interval", "3")
|
||||
prev = readAt()
|
||||
now := readAt()
|
||||
if gap := now.Sub(prev); gap < 2200*time.Millisecond || gap > 6*time.Second {
|
||||
t.Fatalf("frame gap after change to 3 s: %v", gap)
|
||||
}
|
||||
}
|
||||
@@ -1303,6 +1303,13 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
postCommitApply()
|
||||
}
|
||||
|
||||
// V2.0.1 ⓪: a newly created inbound starts with no sing-box
|
||||
// fragment at all (fragments were a migration-era snapshot);
|
||||
// compute its first one from the saved row so an agent-bound
|
||||
// inbound is servable from birth, under the same discipline as
|
||||
// the UpdateInbound recompute.
|
||||
recomputeSingboxFragmentLogged(inbound)
|
||||
|
||||
// A routed mtproto inbound is not an Xray inbound itself, so the runtime
|
||||
// push above only (re)starts the mtg sidecar. The egress SOCKS bridge lives
|
||||
// in the generated config, so force a regen to wire it in.
|
||||
@@ -1950,6 +1957,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
if postCommitApply != nil {
|
||||
postCommitApply()
|
||||
}
|
||||
// V2.0.1 ⓪: refresh the stored sing-box fragment from the saved
|
||||
// row so agent-bound inbounds serve the edit, not the
|
||||
// migration-day snapshot. oldInbound carries the persisted values
|
||||
// (resolved tag, target node, normalized settings).
|
||||
recomputeSingboxFragmentLogged(oldInbound)
|
||||
return inbound, needRestart, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -680,6 +680,17 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
||||
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Probe-only (V2.1.0) lives on the bound agent rows, not the
|
||||
// node row: flip every binding of this node in the same
|
||||
// transaction, so the flag and the node edit land together.
|
||||
// Nil means the form didn't touch the switch.
|
||||
if isAgent && req.ProbeOnly != nil {
|
||||
if err := tx.Model(&model.AgentNode{}).
|
||||
Where("node_id = ?", id).
|
||||
Update("probe_only", *req.ProbeOnly).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.MarkNodeDirtyTx(tx, id)
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -1223,7 +1234,74 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
|
||||
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
|
||||
}
|
||||
|
||||
// agentOnlineWindow is how fresh an agent heartbeat must be for its
|
||||
// node to read online — the same 180s the heartbeat job expires agent
|
||||
// nodes by (agentHeartbeatStaleAfter in internal/web/job: max(3
|
||||
// heartbeat intervals, 120s), so two lost heartbeats don't flap).
|
||||
const agentOnlineWindow = 180 * time.Second
|
||||
|
||||
var errAgentOffline = errors.New("agent node offline: no fresh heartbeat")
|
||||
|
||||
// agentHeartbeatVerdict is the pure decision behind the agent-node
|
||||
// probe: a fresh stamp reads online with the heartbeat AGE as the
|
||||
// latency (there is no dial to time); a stale or never-seen stamp
|
||||
// reads offline with LatencyMs -1. LastError stays empty either way —
|
||||
// the answer comes from the heartbeat, not from a dial failure.
|
||||
func agentHeartbeatVerdict(lastSeenMs int64, now time.Time) (HeartbeatPatch, bool) {
|
||||
patch := HeartbeatPatch{LatencyMs: -1}
|
||||
if lastSeenMs <= 0 {
|
||||
return patch, false
|
||||
}
|
||||
patch.LastHeartbeat = lastSeenMs / 1000
|
||||
if age := now.Sub(time.UnixMilli(lastSeenMs)); age <= agentOnlineWindow {
|
||||
patch.LastHeartbeat = now.Unix()
|
||||
patch.LatencyMs = int(age / time.Millisecond)
|
||||
return patch, true
|
||||
}
|
||||
return patch, false
|
||||
}
|
||||
|
||||
// agentNodeProbe answers a probe for an agent-managed node from the
|
||||
// bound agent's last_seen stamp (Unix ms, the unit the agent
|
||||
// controller stamps). ok=false means the node has no bound agent — or
|
||||
// the lookup failed — and the caller takes the dial path. The stored
|
||||
// telemetry fields carry through unchanged (same as the heartbeat
|
||||
// job's agent-expiry patch); only the error is dropped, so a ⚡ click
|
||||
// also heals the bogus dial errors recorded before this change.
|
||||
func (s *NodeService) agentNodeProbe(n *model.Node) (patch HeartbeatPatch, err error, ok bool) {
|
||||
var lastSeen *int64 // MAX() over zero rows is NULL
|
||||
qerr := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("node_id = ?", n.Id).
|
||||
Select("MAX(last_seen)").
|
||||
Scan(&lastSeen).Error
|
||||
if qerr != nil || lastSeen == nil {
|
||||
return HeartbeatPatch{}, nil, false
|
||||
}
|
||||
patch, online := agentHeartbeatVerdict(*lastSeen, time.Now())
|
||||
patch.XrayVersion = n.XrayVersion
|
||||
patch.PanelVersion = n.PanelVersion
|
||||
patch.Guid = n.Guid
|
||||
patch.CpuPct = n.CpuPct
|
||||
patch.MemPct = n.MemPct
|
||||
patch.UptimeSecs = n.UptimeSecs
|
||||
patch.NetUp = n.NetUp
|
||||
patch.NetDown = n.NetDown
|
||||
patch.XrayState = n.XrayState
|
||||
patch.XrayError = n.XrayError
|
||||
if !online {
|
||||
return patch, errAgentOffline, true
|
||||
}
|
||||
return patch, nil, true
|
||||
}
|
||||
|
||||
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
|
||||
// Agent-managed nodes have no panel to dial — their address row
|
||||
// is a placeholder ("agent") — so a probe answers from the agent
|
||||
// heartbeat instead of dialing a dead URL and persisting a
|
||||
// LastError the agent heartbeat never clears (V2.2.1 ②).
|
||||
if patch, err, ok := s.agentNodeProbe(n); ok {
|
||||
return patch, err
|
||||
}
|
||||
proxyURL := ""
|
||||
if n.OutboundTag != "" {
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// V2.2.1 ②: the ⚡ probe for an agent-managed node is synthesized
|
||||
// from the agent's heartbeat stamp instead of dialing the placeholder
|
||||
// address. These tests pin the pure verdict: fresh stamp → online
|
||||
// with the heartbeat age as latency; stale / never-seen → offline
|
||||
// with LatencyMs -1; LastError empty in every case (a dial error must
|
||||
// never be recorded for an agent node again).
|
||||
|
||||
func TestAgentHeartbeatVerdictFreshIsOnlineWithAgeLatency(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
lastSeenMs := now.Add(-30 * time.Second).UnixMilli()
|
||||
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
|
||||
if !online {
|
||||
t.Fatal("30s-old heartbeat must read online")
|
||||
}
|
||||
if patch.LatencyMs != 30_000 {
|
||||
t.Fatalf("LatencyMs = %d, want heartbeat age 30000", patch.LatencyMs)
|
||||
}
|
||||
if patch.LastHeartbeat != now.Unix() {
|
||||
t.Fatalf("LastHeartbeat = %d, want now %d", patch.LastHeartbeat, now.Unix())
|
||||
}
|
||||
if patch.LastError != "" {
|
||||
t.Fatalf("LastError = %q, want empty", patch.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeartbeatVerdictWindowEdge(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow).UnixMilli(), now); !online {
|
||||
t.Fatal("heartbeat exactly at the window edge must read online")
|
||||
}
|
||||
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow-time.Second).UnixMilli(), now); online {
|
||||
t.Fatal("heartbeat past the window must read offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeartbeatVerdictStaleIsOffline(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
lastSeenMs := now.Add(-10 * time.Minute).UnixMilli()
|
||||
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
|
||||
if online {
|
||||
t.Fatal("10min-old heartbeat must read offline")
|
||||
}
|
||||
if patch.LatencyMs != -1 {
|
||||
t.Fatalf("LatencyMs = %d, want -1", patch.LatencyMs)
|
||||
}
|
||||
if patch.LastHeartbeat != lastSeenMs/1000 {
|
||||
t.Fatalf("LastHeartbeat = %d, want the truthful last-seen %d", patch.LastHeartbeat, lastSeenMs/1000)
|
||||
}
|
||||
if patch.LastError != "" {
|
||||
t.Fatalf("LastError = %q, want empty", patch.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeartbeatVerdictNeverSeenIsOffline(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
patch, online := agentHeartbeatVerdict(0, now)
|
||||
if online {
|
||||
t.Fatal("never-seen agent must read offline")
|
||||
}
|
||||
if patch.LatencyMs != -1 || patch.LastHeartbeat != 0 || patch.LastError != "" {
|
||||
t.Fatalf("patch = %+v, want zero stamp, latency -1, no error", patch)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,28 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// The panel-version cell must read as the bare A-form across the
|
||||
// fleet: pre-fix agents report the doubled "AV2.1.0" and are repaired
|
||||
// at view assembly; legacy build versions keep their "agent " prefix.
|
||||
func TestAgentVersionCell(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"A2.1.0": "A2.1.0",
|
||||
"A1.0.0": "A1.0.0",
|
||||
"AV2.1.0": "A2.1.0",
|
||||
"AV2.0.1": "A2.0.1",
|
||||
"agent AV2.0.3": "A2.0.3",
|
||||
"3.7.0-agent.1": "agent 3.7.0-agent.1",
|
||||
"agent A2.1.0": "A2.1.0",
|
||||
" A2.1.0 ": "A2.1.0",
|
||||
"some-dev-build": "agent some-dev-build",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := agentVersionCell(in); got != want {
|
||||
t.Errorf("agentVersionCell(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P13: annotateAgentTelemetry overlays the agent-reported heartbeat
|
||||
// telemetry onto agent node views only — panel version becomes
|
||||
// "agent <version>", xray version/uptime/latency/CPU/mem/IPs take the
|
||||
@@ -28,6 +50,7 @@ func TestAnnotateAgentTelemetry(t *testing.T) {
|
||||
TokenHash: "hash-reporting",
|
||||
Name: "agent-node", NodeID: &agentNode.Id,
|
||||
AgentVersion: "3.7.0-agent.1", XrayVersion: "26.6.27",
|
||||
SingboxVersion: "1.13.21",
|
||||
UptimeSecs: 90061, LatencyMs: 12, CpuPct: 3.5, MemPct: 41.2,
|
||||
IPv4: "203.0.113.7", IPv6: "2001:db8::7", TelemetryAt: 1700000000000,
|
||||
}
|
||||
@@ -53,7 +76,7 @@ func TestAnnotateAgentTelemetry(t *testing.T) {
|
||||
if av.NodeType != "agent" {
|
||||
t.Fatalf("node 7401 type = %q, want agent", av.NodeType)
|
||||
}
|
||||
if av.XrayVersion != "26.6.27" || av.PanelVersion != "agent 3.7.0-agent.1" ||
|
||||
if av.XrayVersion != "26.6.27" || av.SingboxVersion != "1.13.21" || av.PanelVersion != "agent 3.7.0-agent.1" ||
|
||||
av.UptimeSecs != 90061 || av.LatencyMs != 12 ||
|
||||
av.CpuPct != 3.5 || av.MemPct != 41.2 ||
|
||||
av.Ipv4 != "203.0.113.7" || av.Ipv6 != "2001:db8::7" {
|
||||
|
||||
@@ -40,6 +40,11 @@ type NodeView struct {
|
||||
// traditional nodes and for agents with no known address yet.
|
||||
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
|
||||
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
||||
// ProbeOnly marks an agent node as probe-only (V2.1.0): no core
|
||||
// runs on it, only the probe channels. Overlaid from the bound
|
||||
// agent row like the update bookkeeping below; false (and absent)
|
||||
// for traditional nodes and unflagged agents.
|
||||
ProbeOnly bool `json:"probeOnly,omitempty" example:"false"`
|
||||
// Agent one-click update bookkeeping (item 20), agent nodes only:
|
||||
// the last intent's state/target and, on failure, its error.
|
||||
AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"`
|
||||
@@ -50,6 +55,11 @@ type NodeView struct {
|
||||
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
|
||||
LatencyMs int `json:"latencyMs" example:"42"`
|
||||
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
||||
// SingboxVersion is the bound agent's sing-box core version
|
||||
// (V2.0.1 ②), overlaid from agent telemetry like XrayVersion;
|
||||
// empty for traditional nodes and agents that never reported
|
||||
// one. The nodes page prefers it over XrayVersion when set.
|
||||
SingboxVersion string `json:"singboxVersion" example:"1.13.21"`
|
||||
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
|
||||
CpuPct float64 `json:"cpuPct" example:"12.5"`
|
||||
MemPct float64 `json:"memPct" example:"45.2"`
|
||||
@@ -180,6 +190,30 @@ func annotateNodeTypes(views []*NodeView) {
|
||||
}
|
||||
}
|
||||
|
||||
// agentVersionCell renders one agent's reported version for the
|
||||
// node list's panel-version cell, uniformly across the fleet:
|
||||
// - current agents report the A-series display version and are
|
||||
// shown bare ("A2.1.0");
|
||||
// - agents built while releases injected the V-form number report
|
||||
// the doubled "AV2.1.0" (see config.GetAgentDisplayVersion) and
|
||||
// are repaired to the bare A-form here, so those nodes read
|
||||
// correctly without waiting for their next agent update;
|
||||
// - pre-display-version agents report the internal build version
|
||||
// and keep the historical "agent " prefix.
|
||||
func agentVersionCell(reported string) string {
|
||||
s := strings.TrimSpace(reported)
|
||||
s = strings.TrimPrefix(s, "agent ")
|
||||
if len(s) > 2 && (s[0] == 'A' || s[0] == 'a') && (s[1] == 'V' || s[1] == 'v') {
|
||||
if _, ok := config.ParseDisplayVersion(s[1:]); ok {
|
||||
return "A" + s[2:]
|
||||
}
|
||||
}
|
||||
if _, ok := config.ParseDisplayVersion(s); ok && strings.HasPrefix(s, "A") {
|
||||
return s
|
||||
}
|
||||
return "agent " + s
|
||||
}
|
||||
|
||||
// annotateAgentTelemetry overlays the latest heartbeat telemetry reported
|
||||
// by each node's bound agent onto its view (P13): xray version, the
|
||||
// "agent <version>" panel-version label, host uptime, node->master latency,
|
||||
@@ -220,21 +254,21 @@ func annotateAgentTelemetry(views []*NodeView) {
|
||||
// its first telemetry report: an agent that has only registered
|
||||
// already has a master-observed source address.
|
||||
v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag)
|
||||
// Probe-only (V2.1.0) is a stored flag, not telemetry: it must
|
||||
// show even before the agent's first report, so it is overlaid
|
||||
// here, ahead of the telemetry early-exit below.
|
||||
v.ProbeOnly = ag.ProbeOnly
|
||||
if ag.TelemetryAt == 0 {
|
||||
continue
|
||||
}
|
||||
if ag.XrayVersion != "" {
|
||||
v.XrayVersion = ag.XrayVersion
|
||||
}
|
||||
if ag.AgentVersion != "" {
|
||||
// New agents report the A-series display version, shown
|
||||
// verbatim; older agents report the internal build version
|
||||
// and keep the historical "agent " prefix.
|
||||
if _, ok := config.ParseDisplayVersion(ag.AgentVersion); ok && strings.HasPrefix(ag.AgentVersion, "A") {
|
||||
v.PanelVersion = ag.AgentVersion
|
||||
} else {
|
||||
v.PanelVersion = "agent " + ag.AgentVersion
|
||||
if ag.SingboxVersion != "" {
|
||||
v.SingboxVersion = ag.SingboxVersion
|
||||
}
|
||||
if ag.AgentVersion != "" {
|
||||
v.PanelVersion = agentVersionCell(ag.AgentVersion)
|
||||
v.AgentUpdateState = ag.UpdateState
|
||||
v.AgentUpdateError = ag.UpdateError
|
||||
v.AgentUpdateTarget = ag.UpdateTarget
|
||||
@@ -270,6 +304,11 @@ type NodeMutationRequest struct {
|
||||
// agent nodes. Agent nodes skip the panel reachability probe and the API
|
||||
// token requirement; an agent token is issued on creation instead.
|
||||
NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"`
|
||||
// ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A
|
||||
// pointer so updates can tell "leave the flag alone" (nil) from an
|
||||
// explicit on/off; applied to the bound agent rows, agent nodes
|
||||
// only. On create it presets the flag on the freshly issued agent.
|
||||
ProbeOnly *bool `json:"probeOnly,omitempty" form:"probeOnly"`
|
||||
}
|
||||
|
||||
func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// V2.1.0: the node form's probeOnly switch persists onto the bound
|
||||
// agent rows via UpdateFromRequest (nil leaves the flag untouched),
|
||||
// and the list view overlays it from the binding.
|
||||
func TestNodeProbeOnlyUpdateAndView(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
node := &model.Node{Id: 7601, Name: "probe-svc", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
ag := &model.AgentNode{TokenHash: "hash-probe-svc", Name: "probe-svc", NodeID: &node.Id}
|
||||
if err := db.Create(ag).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
svc := &NodeService{}
|
||||
truth := true
|
||||
lies := false
|
||||
base := func() *NodeMutationRequest {
|
||||
return &NodeMutationRequest{
|
||||
Name: "probe-svc", NodeType: "agent", Enable: true,
|
||||
Scheme: "https", BasePath: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// Nil: flag untouched (still false).
|
||||
if err := svc.UpdateFromRequest(node.Id, base()); err != nil {
|
||||
t.Fatalf("update (nil): %v", err)
|
||||
}
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, ag.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.ProbeOnly {
|
||||
t.Fatal("nil probeOnly must not set the flag")
|
||||
}
|
||||
|
||||
// True: flag set on the agent row, and the annotated view shows it.
|
||||
req := base()
|
||||
req.ProbeOnly = &truth
|
||||
if err := svc.UpdateFromRequest(node.Id, req); err != nil {
|
||||
t.Fatalf("update (true): %v", err)
|
||||
}
|
||||
if err := db.First(&row, ag.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !row.ProbeOnly {
|
||||
t.Fatal("probeOnly=true not persisted on the agent row")
|
||||
}
|
||||
view, err := svc.GetViewById(node.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetViewById: %v", err)
|
||||
}
|
||||
if !view.ProbeOnly {
|
||||
t.Fatal("view probeOnly = false, want true from the binding")
|
||||
}
|
||||
|
||||
// False: explicit off wins over the stored value.
|
||||
req = base()
|
||||
req.ProbeOnly = &lies
|
||||
if err := svc.UpdateFromRequest(node.Id, req); err != nil {
|
||||
t.Fatalf("update (false): %v", err)
|
||||
}
|
||||
if err := db.First(&row, ag.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.ProbeOnly {
|
||||
t.Fatal("probeOnly=false not persisted on the agent row")
|
||||
}
|
||||
}
|
||||
@@ -164,13 +164,38 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
// display version. An unparseable tag (not a V-series release) yields ""
|
||||
// so the UI falls back to the raw fields.
|
||||
func displayUpdateFields(latestTag string) (string, bool) {
|
||||
return displayUpdateFieldsFor(latestTag, config.GetMasterDisplayVersion(), config.GetBaseVersion())
|
||||
}
|
||||
|
||||
// displayUpdateFieldsFor is displayUpdateFields with the running
|
||||
// build's versions as inputs. When the DISPLAY comparison cannot
|
||||
// judge — the running build's display version is itself unparseable,
|
||||
// the "VV2.1.0" double-letter builds — the verdict falls back to the
|
||||
// raw version comparison instead of reporting "no update": the
|
||||
// display path used to swallow a real update entirely (the panel
|
||||
// showed no prompt while /releases/latest happily served the newer
|
||||
// tag, and the native updater refused to run). A parseable display
|
||||
// version keeps the segment rule exactly as before.
|
||||
func displayUpdateFieldsFor(latestTag, currentDisplay, currentRaw string) (string, bool) {
|
||||
parts, ok := config.ParseDisplayVersion(latestTag)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2])
|
||||
cmp, ok := config.CompareDisplayVersion(disp, config.GetMasterDisplayVersion())
|
||||
return disp, ok && cmp > 0
|
||||
cmp, ok := config.CompareDisplayVersion(disp, currentDisplay)
|
||||
if !ok {
|
||||
// Raw fallback: judge the latest tag against the running
|
||||
// build's raw base version, parsed with the same tolerant
|
||||
// display parser (bare and V/A forms both parse). An
|
||||
// unparseable raw base yields no verdict → false. This is
|
||||
// deliberately NOT isNewerVersion: on this fork's uppercase-V
|
||||
// tags that helper degrades to string inequality (its
|
||||
// normalizer strips only a lowercase "v"), which would report
|
||||
// a phantom update whenever latest merely differs in form.
|
||||
rawCmp, rawOk := config.CompareDisplayVersion(disp, currentRaw)
|
||||
return disp, rawOk && rawCmp > 0
|
||||
}
|
||||
return disp, cmp > 0
|
||||
}
|
||||
|
||||
// devChannelActive reports whether self-update should track the rolling dev
|
||||
|
||||
@@ -25,6 +25,42 @@ func TestDisplayUpdateFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// V2.2.1 ③: when the running build's display version is unparseable
|
||||
// (the "VV2.1.0" double-letter builds), the display comparison cannot
|
||||
// judge and the verdict must fall back to the raw version comparison
|
||||
// instead of swallowing the update. A parseable display version keeps
|
||||
// the segment rule — the raw string plays no part there.
|
||||
func TestDisplayUpdateFieldsForFallback(t *testing.T) {
|
||||
// Unparseable display "VV2.1.0", newer latest → available via raw.
|
||||
if disp, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "2.1.0"); disp != "V2.2.0" || !avail {
|
||||
t.Fatalf("VV display, newer latest = %q, %v; want V2.2.0, true", disp, avail)
|
||||
}
|
||||
// Unparseable display, latest equal to the raw base → not
|
||||
// available, in both raw forms (bare and V-prefixed).
|
||||
if disp, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "2.1.0"); disp != "V2.1.0" || avail {
|
||||
t.Fatalf("VV display, same latest (bare raw) = %q, %v; want V2.1.0, false", disp, avail)
|
||||
}
|
||||
if _, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "V2.1.0"); avail {
|
||||
t.Fatal("VV display, same latest (V raw) must not report an update")
|
||||
}
|
||||
// Unparseable display AND unparseable raw base → no verdict, false.
|
||||
if _, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "3.7.0-agent.1"); avail {
|
||||
t.Fatal("unparseable raw base must yield no update verdict")
|
||||
}
|
||||
// Parseable display: segment rule decides, raw base is ignored
|
||||
// even when it disagrees.
|
||||
if disp, avail := displayUpdateFieldsFor("V2.2.0", "V2.1.0", "9.9.9"); disp != "V2.2.0" || !avail {
|
||||
t.Fatalf("parseable display, newer = %q, %v; want V2.2.0, true", disp, avail)
|
||||
}
|
||||
if _, avail := displayUpdateFieldsFor("V2.1.0", "V2.1.0", "1.0.0"); avail {
|
||||
t.Fatal("parseable display, equal versions must not report an update (raw base must not leak in)")
|
||||
}
|
||||
// Unparseable latest tag still yields "" so the UI uses raw fields.
|
||||
if disp, avail := displayUpdateFieldsFor("3.7.0-agent.1", "VV2.1.0", "2.1.0"); disp != "" || avail {
|
||||
t.Fatalf("legacy tag = %q, %v; want empty, false", disp, avail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUnitFromCgroup(t *testing.T) {
|
||||
content := "0::/system.slice/x-ui.service\n"
|
||||
if got := serviceUnitFromCgroup(content); got != "x-ui" {
|
||||
|
||||
@@ -30,6 +30,18 @@ type Meta struct {
|
||||
CountryPin string
|
||||
IPv4Pin string
|
||||
IPv6Pin string
|
||||
// TrafficCalUpGB / TrafficCalDownGB are the operator's ledger
|
||||
// corrections in GB (V2.0.1), either sign: added to the counted
|
||||
// uplink (tx) / downlink (rx) figures wherever the ledger is
|
||||
// read — panel statistics, the public page and traffic alerts.
|
||||
// The stored values are the STANDING accumulated correction;
|
||||
// the edit form submits one-shot deltas that MergeCalDelta folds
|
||||
// into them (V2.2.1 ⑥b).
|
||||
TrafficCalUpGB float64
|
||||
TrafficCalDownGB float64
|
||||
// StatIface pins the one interface whose counters the agent
|
||||
// books (V2.0.1); "" keeps the automatic counted-set selection.
|
||||
StatIface string
|
||||
}
|
||||
|
||||
// DefaultMeta returns the metadata of an agent that has no row yet —
|
||||
@@ -48,7 +60,8 @@ func DefaultMeta(agentID int64) Meta {
|
||||
|
||||
const metaColumns = `agent_id, price, currency, billing_cycle, expires_at,
|
||||
traffic_limit, traffic_mode, traffic_reset_day, public, sort,
|
||||
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin`
|
||||
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin,
|
||||
traffic_cal_up_gb, traffic_cal_down_gb, stat_iface`
|
||||
|
||||
func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
||||
var m Meta
|
||||
@@ -56,7 +69,8 @@ func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
||||
var public, notify int64
|
||||
if err := sc.Scan(&m.AgentID, &m.Price, &m.Currency, &m.BillingCycle, &expires,
|
||||
&m.TrafficLimit, &m.TrafficMode, &m.TrafficResetDay, &public, &m.Sort,
|
||||
&m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil {
|
||||
&m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin,
|
||||
&m.TrafficCalUpGB, &m.TrafficCalDownGB, &m.StatIface); err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
if expires.Valid && strings.TrimSpace(expires.String) != "" {
|
||||
@@ -107,7 +121,7 @@ func (s *Store) UpsertMeta(m Meta) error {
|
||||
expires = *m.ExpiresAt
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
price=excluded.price, currency=excluded.currency,
|
||||
billing_cycle=excluded.billing_cycle, expires_at=excluded.expires_at,
|
||||
@@ -116,13 +130,41 @@ func (s *Store) UpsertMeta(m Meta) error {
|
||||
sort=excluded.sort, group_name=excluded.group_name,
|
||||
public_remark=excluded.public_remark, notify=excluded.notify,
|
||||
country_pin=excluded.country_pin, ipv4_pin=excluded.ipv4_pin,
|
||||
ipv6_pin=excluded.ipv6_pin`,
|
||||
ipv6_pin=excluded.ipv6_pin,
|
||||
traffic_cal_up_gb=excluded.traffic_cal_up_gb,
|
||||
traffic_cal_down_gb=excluded.traffic_cal_down_gb,
|
||||
stat_iface=excluded.stat_iface`,
|
||||
m.AgentID, m.Price, m.Currency, m.BillingCycle, expires,
|
||||
m.TrafficLimit, m.TrafficMode, m.TrafficResetDay, boolToInt(m.Public), m.Sort,
|
||||
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin)
|
||||
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin,
|
||||
m.TrafficCalUpGB, m.TrafficCalDownGB, m.StatIface)
|
||||
return err
|
||||
}
|
||||
|
||||
// MergeCalDelta folds a probe-meta form submission into the stored
|
||||
// row under the one-shot calibration semantics (V2.2.1 ⑥b): the
|
||||
// form's calibration fields are a correction TO APPLY NOW, not the
|
||||
// new standing value. A non-zero submitted value adds onto the stored
|
||||
// standing correction; a zero field (an absent field is the same
|
||||
// zero — the wire type cannot distinguish them, and both mean
|
||||
// "nothing to apply") keeps the stored value untouched. Every other
|
||||
// field takes the submitted value, as UpsertMeta's full replace
|
||||
// does. Paired with the form clearing its calibration inputs after a
|
||||
// successful save, a later unrelated save can never re-apply an old
|
||||
// correction.
|
||||
func MergeCalDelta(existing, submitted Meta) Meta {
|
||||
out := submitted
|
||||
out.TrafficCalUpGB = existing.TrafficCalUpGB
|
||||
if submitted.TrafficCalUpGB != 0 {
|
||||
out.TrafficCalUpGB += submitted.TrafficCalUpGB
|
||||
}
|
||||
out.TrafficCalDownGB = existing.TrafficCalDownGB
|
||||
if submitted.TrafficCalDownGB != 0 {
|
||||
out.TrafficCalDownGB += submitted.TrafficCalDownGB
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int64 {
|
||||
if b {
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// V2.2.1 ⑥: GB entries convert on the binary convention (1024^3)
|
||||
// every formatter uses, stored decimal-era limits are rescaled once,
|
||||
// and calibration submissions are one-shot deltas folded into the
|
||||
// standing correction — never re-applied by a later unrelated save.
|
||||
|
||||
func TestCalBytesBinaryConvention(t *testing.T) {
|
||||
if got := calBytes(3000); got != 3000*(1<<30) {
|
||||
t.Fatalf("calBytes(3000) = %d, want %d (3000 GiB)", got, 3000*(1<<30))
|
||||
}
|
||||
if got := calBytes(0); got != 0 {
|
||||
t.Fatalf("calBytes(0) = %d, want 0", got)
|
||||
}
|
||||
if got := calBytes(-1.5); got != -1610612736 {
|
||||
t.Fatalf("calBytes(-1.5) = %d, want -1610612736", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGBConventionMigrationRescalesLimitsOnce(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "probe.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
// Seed a legacy row as a pre-V2.2.1 database holds it — 3000 GB
|
||||
// typed at the decimal convention = 3.0e12 stored bytes — and
|
||||
// strike the migration mark so the next open sees an unmigrated
|
||||
// database.
|
||||
if _, err := st.db.Exec(`INSERT INTO probe_meta (agent_id, traffic_limit) VALUES (9, 3000000000000)`); err != nil {
|
||||
t.Fatalf("seed legacy limit: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec(`DELETE FROM probe_setting WHERE key = ?`, gbConventionKey); err != nil {
|
||||
t.Fatalf("strike mark: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
st2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen (migration run): %v", err)
|
||||
}
|
||||
m, found, err := st2.Meta(9)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("meta after migration: found=%v err=%v", found, err)
|
||||
}
|
||||
// 3.0e12 x 1024^3/1e9 = 3000 x 2^30 exactly (~3.221e12).
|
||||
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
|
||||
t.Fatalf("migrated limit = %d, want %d", m.TrafficLimit, want)
|
||||
}
|
||||
if err := st2.Close(); err != nil {
|
||||
t.Fatalf("close 2: %v", err)
|
||||
}
|
||||
|
||||
st3, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen 2: %v", err)
|
||||
}
|
||||
defer st3.Close()
|
||||
m, found, err = st3.Meta(9)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("meta after second open: found=%v err=%v", found, err)
|
||||
}
|
||||
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
|
||||
t.Fatalf("limit after second open = %d, want %d (no double rescale)", m.TrafficLimit, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneShotCalibrationSaveFlow(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
book(t, st, 5, "boot-a", 1000, 1000, now, nil, 0) // baseline: books 0
|
||||
book(t, st, 5, "boot-a", 3000, 2000, now, nil, 0) // books rx 2000, tx 1000
|
||||
|
||||
// First save carries a +1 GB uplink correction delta.
|
||||
submitted := DefaultMeta(5)
|
||||
submitted.TrafficCalUpGB = 1
|
||||
merged := MergeCalDelta(DefaultMeta(5), submitted)
|
||||
if merged.TrafficCalUpGB != 1 {
|
||||
t.Fatalf("standing after first fold = %v, want 1", merged.TrafficCalUpGB)
|
||||
}
|
||||
if err := st.UpsertMeta(merged); err != nil {
|
||||
t.Fatalf("upsert 1: %v", err)
|
||||
}
|
||||
after1, ok, err := st.TrafficFor(5)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("traffic after save 1: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if want := int64(1000) + (1 << 30); after1.TotalTx != want {
|
||||
t.Fatalf("TotalTx after save 1 = %d, want %d", after1.TotalTx, want)
|
||||
}
|
||||
if after1.TotalRx != 2000 {
|
||||
t.Fatalf("TotalRx after save 1 = %d, want 2000 (uncalibrated)", after1.TotalRx)
|
||||
}
|
||||
|
||||
// Second save changes another field; the (cleared) form submits
|
||||
// zero calibration — the calibrated figure must not move again.
|
||||
stored, found, err := st.Meta(5)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("stored meta: found=%v err=%v", found, err)
|
||||
}
|
||||
sub2 := DefaultMeta(5)
|
||||
sub2.GroupName = "g2"
|
||||
merged2 := MergeCalDelta(stored, sub2)
|
||||
if merged2.TrafficCalUpGB != 1 {
|
||||
t.Fatalf("standing after zero-delta save = %v, want 1 (unchanged)", merged2.TrafficCalUpGB)
|
||||
}
|
||||
if err := st.UpsertMeta(merged2); err != nil {
|
||||
t.Fatalf("upsert 2: %v", err)
|
||||
}
|
||||
after2, ok, err := st.TrafficFor(5)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("traffic after save 2: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if after2.TotalTx != after1.TotalTx {
|
||||
t.Fatalf("TotalTx moved on an unrelated save: %d -> %d (double-applied calibration)", after1.TotalTx, after2.TotalTx)
|
||||
}
|
||||
|
||||
// A further non-zero delta accumulates exactly once more.
|
||||
sub3 := DefaultMeta(5)
|
||||
sub3.TrafficCalUpGB = 0.5
|
||||
merged3 := MergeCalDelta(merged2, sub3)
|
||||
if merged3.TrafficCalUpGB != 1.5 {
|
||||
t.Fatalf("standing after second delta = %v, want 1.5", merged3.TrafficCalUpGB)
|
||||
}
|
||||
if err := st.UpsertMeta(merged3); err != nil {
|
||||
t.Fatalf("upsert 3: %v", err)
|
||||
}
|
||||
after3, _, err := st.TrafficFor(5)
|
||||
if err != nil {
|
||||
t.Fatalf("traffic after save 3: %v", err)
|
||||
}
|
||||
if want := int64(1000) + calBytes(1.5); after3.TotalTx != want {
|
||||
t.Fatalf("TotalTx after save 3 = %d, want %d", after3.TotalTx, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// V2.0.1 probe batch, storage side: the calibration columns and the
|
||||
// counted-interface pin round-trip through probe_meta, pre-V2.0.1
|
||||
// databases gain the columns on open, and the traffic reads apply
|
||||
// the calibration (floored at zero) on every figure.
|
||||
|
||||
func TestMetaCalibrationAndIfaceRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
meta := DefaultMeta(7)
|
||||
meta.TrafficCalUpGB = 2.5
|
||||
meta.TrafficCalDownGB = -1.25
|
||||
meta.StatIface = "eth0"
|
||||
if err := st.UpsertMeta(meta); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
back, found, err := st.Meta(7)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("meta read: found=%v err=%v", found, err)
|
||||
}
|
||||
if back.TrafficCalUpGB != 2.5 || back.TrafficCalDownGB != -1.25 || back.StatIface != "eth0" {
|
||||
t.Errorf("round trip = up %v down %v iface %q", back.TrafficCalUpGB, back.TrafficCalDownGB, back.StatIface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesLegacyMetaColumns(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "probe.db")
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open raw: %v", err)
|
||||
}
|
||||
// A pre-V2.0.1 probe_meta: the original 16 columns only (shape
|
||||
// copied from the V2.0.0 schema in git history).
|
||||
if _, err := db.Exec(`CREATE TABLE probe_meta (
|
||||
agent_id INTEGER PRIMARY KEY, price REAL NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'USD', billing_cycle TEXT NOT NULL DEFAULT 'monthly',
|
||||
expires_at TEXT, traffic_limit INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_mode TEXT NOT NULL DEFAULT 'sum', traffic_reset_day INTEGER NOT NULL DEFAULT 1,
|
||||
public INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0,
|
||||
group_name TEXT NOT NULL DEFAULT '', public_remark TEXT NOT NULL DEFAULT '',
|
||||
notify INTEGER NOT NULL DEFAULT 0, country_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv4_pin TEXT NOT NULL DEFAULT '', ipv6_pin TEXT NOT NULL DEFAULT '')`); err != nil {
|
||||
t.Fatalf("create legacy table: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO probe_meta (agent_id, group_name) VALUES (3, 'legacy')`); err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
back, found, err := st.Meta(3)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("meta read: found=%v err=%v", found, err)
|
||||
}
|
||||
if back.GroupName != "legacy" || back.TrafficCalUpGB != 0 || back.TrafficCalDownGB != 0 || back.StatIface != "" {
|
||||
t.Errorf("legacy row after migration = %+v", back)
|
||||
}
|
||||
back.StatIface = "ens3"
|
||||
if err := st.UpsertMeta(back); err != nil {
|
||||
t.Fatalf("upsert after migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCalibrationAppliedAndFloored(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
// Book 3 GB down / 1 GB up of counted traffic (baseline, delta).
|
||||
book(t, st, 5, "k1/fp", 0, 0, now, nil, 0)
|
||||
book(t, st, 5, "k1/fp", 3_000_000_000, 1_000_000_000, now.Add(time.Minute), nil, now.Unix())
|
||||
|
||||
meta := DefaultMeta(5)
|
||||
meta.TrafficCalUpGB = -2 // correction exceeds the counted upload
|
||||
meta.TrafficCalDownGB = 0.5 // half a GB the counters missed
|
||||
if err := st.UpsertMeta(meta); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
all, err := st.AllTraffic()
|
||||
if err != nil {
|
||||
t.Fatalf("AllTraffic: %v", err)
|
||||
}
|
||||
tr := all[5]
|
||||
// V2.2.1 ⑥: calibration converts on the binary convention —
|
||||
// +0.5 GB = 0.5 x 2^30 = 536870912 B (was 5e8 at the old 1e9).
|
||||
if tr.TotalRx != 3_536_870_912 || tr.MonthRx != 3_536_870_912 || tr.DayRx != 3_536_870_912 {
|
||||
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3536870912 each", tr.TotalRx, tr.MonthRx, tr.DayRx)
|
||||
}
|
||||
if tr.TotalTx != 0 || tr.MonthTx != 0 || tr.DayTx != 0 {
|
||||
t.Errorf("tx figures after -2 GB calibration = %d/%d/%d, want 0 (floored)", tr.TotalTx, tr.MonthTx, tr.DayTx)
|
||||
}
|
||||
|
||||
one, found, err := st.TrafficFor(5)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("TrafficFor: found=%v err=%v", found, err)
|
||||
}
|
||||
if one.TotalRx != tr.TotalRx || one.TotalTx != tr.TotalTx {
|
||||
t.Errorf("TrafficFor disagrees with AllTraffic: %+v vs %+v", one, tr)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,12 @@ CREATE TABLE IF NOT EXISTS probe_meta (
|
||||
notify INTEGER NOT NULL DEFAULT 0,
|
||||
country_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv4_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv6_pin TEXT NOT NULL DEFAULT ''
|
||||
ipv6_pin TEXT NOT NULL DEFAULT '',
|
||||
-- V2.0.1 probe batch: operator traffic calibration (GB, either
|
||||
-- sign) and the counted-interface pin ('' = automatic selection).
|
||||
traffic_cal_up_gb REAL NOT NULL DEFAULT 0,
|
||||
traffic_cal_down_gb REAL NOT NULL DEFAULT 0,
|
||||
stat_iface TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Monotonic byte counters that survive agent reboots and master
|
||||
@@ -204,9 +209,98 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("probe schema: %w", err)
|
||||
}
|
||||
if err := migrateMetaColumns(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("probe schema migration: %w", err)
|
||||
}
|
||||
if err := migrateGBConvention(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("probe gb migration: %w", err)
|
||||
}
|
||||
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
|
||||
}
|
||||
|
||||
// gbConventionKey is the probe_setting mark recording that the
|
||||
// V2.2.1 ⑥ traffic-limit rescale has been applied to this database.
|
||||
const gbConventionKey = "gb_1024_migrated_v1"
|
||||
|
||||
// migrateGBConvention rescales stored probe_meta traffic limits from
|
||||
// the old decimal entry convention to the unified binary one, once.
|
||||
// Limits are typed in GB and were stored as GB × 1e9 bytes, while
|
||||
// every byte formatter (panel, public page) divides by 1024^3 — a
|
||||
// 3000 GB limit displayed as 2.73 TB instead of 2.93 TB. The GB the
|
||||
// operator typed is the intent, so stored bytes are scaled by
|
||||
// 1024^3/1e9 = 1.073741824 and now read back correctly under the
|
||||
// binary conversion. Only traffic_limit is byte-denominated:
|
||||
// calibration values are stored in GB (their byte conversion,
|
||||
// calBytes, carries the fix) and price/sort/reset-day are unitless —
|
||||
// neither is touched here. The probe_setting mark makes the rescale
|
||||
// run exactly once per database.
|
||||
func migrateGBConvention(db *sql.DB) error {
|
||||
var v string
|
||||
err := db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, gbConventionKey).Scan(&v)
|
||||
if err == nil {
|
||||
return nil // already applied
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`UPDATE probe_meta SET traffic_limit = CAST(ROUND(traffic_limit * 1.073741824) AS INTEGER) WHERE traffic_limit > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, '1')`, gbConventionKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// metaColumnMigrations are the probe_meta columns added after the
|
||||
// table's first release; CREATE TABLE IF NOT EXISTS never retrofits
|
||||
// them onto an existing database, so they are added here, once, by
|
||||
// name check (SQLite has no ADD COLUMN IF NOT EXISTS).
|
||||
var metaColumnMigrations = []struct{ name, ddl string }{
|
||||
{"traffic_cal_up_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_up_gb REAL NOT NULL DEFAULT 0`},
|
||||
{"traffic_cal_down_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_down_gb REAL NOT NULL DEFAULT 0`},
|
||||
{"stat_iface", `ALTER TABLE probe_meta ADD COLUMN stat_iface TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
|
||||
func migrateMetaColumns(db *sql.DB) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(probe_meta)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
have[name] = true
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range metaColumnMigrations {
|
||||
if have[m.name] {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(m.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the database handle.
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
|
||||
@@ -320,15 +320,57 @@ func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) {
|
||||
if err != nil {
|
||||
return Traffic{}, false, err
|
||||
}
|
||||
return gate(t, dayStart, resetDay, time.Now()), true, nil
|
||||
gated := gate(t, dayStart, resetDay, time.Now())
|
||||
if meta, ok, err := s.Meta(agentID); err != nil {
|
||||
return Traffic{}, false, err
|
||||
} else if ok {
|
||||
gated = applyCalibration(gated, meta.TrafficCalUpGB, meta.TrafficCalDownGB)
|
||||
}
|
||||
return gated, true, nil
|
||||
}
|
||||
|
||||
// AllTraffic returns every agent's ledger in one query, gated as in
|
||||
// TrafficFor — the bulk read port for B3's node list.
|
||||
// calBytes converts a GB calibration value to bytes on the panel's
|
||||
// binary convention, 1 GB = 1024^3 B — the convention every byte
|
||||
// formatter in the panel and on the public page uses, and the one
|
||||
// the traffic limit's GB entry converts with (V2.2.1 ⑥; the decimal
|
||||
// 1e9 stood here before, which made a 3000 GB limit read as 2.73 TB
|
||||
// instead of 2.93 TB wherever bytes were formatted).
|
||||
func calBytes(gb float64) int64 {
|
||||
return int64(math.Round(gb * (1 << 30)))
|
||||
}
|
||||
|
||||
// applyCalibration shifts a ledger row by the operator's per-node
|
||||
// corrections (V2.0.1): uplink correction on every tx figure,
|
||||
// downlink on every rx figure. A correction may exceed the counted
|
||||
// value (a node re-imaged mid-period); figures floor at zero rather
|
||||
// than going negative.
|
||||
func applyCalibration(t Traffic, calUpGB, calDownGB float64) Traffic {
|
||||
up, down := calBytes(calUpGB), calBytes(calDownGB)
|
||||
if up == 0 && down == 0 {
|
||||
return t
|
||||
}
|
||||
floor := func(v, delta int64) int64 {
|
||||
if v += delta; v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
t.TotalTx = floor(t.TotalTx, up)
|
||||
t.MonthTx = floor(t.MonthTx, up)
|
||||
t.DayTx = floor(t.DayTx, up)
|
||||
t.TotalRx = floor(t.TotalRx, down)
|
||||
t.MonthRx = floor(t.MonthRx, down)
|
||||
t.DayRx = floor(t.DayRx, down)
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *Store) AllTraffic() (map[int64]Traffic, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.agent_id, t.total_rx, t.total_tx, t.month_rx, t.month_tx, t.month_start,
|
||||
t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1)
|
||||
t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1),
|
||||
COALESCE(m.traffic_cal_up_gb, 0), COALESCE(m.traffic_cal_down_gb, 0)
|
||||
FROM probe_traffic t LEFT JOIN probe_meta m ON m.agent_id = t.agent_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -340,11 +382,12 @@ func (s *Store) AllTraffic() (map[int64]Traffic, error) {
|
||||
var id, resetDay int64
|
||||
var t Traffic
|
||||
var dayStart string
|
||||
var calUpGB, calDownGB float64
|
||||
if err := rows.Scan(&id, &t.TotalRx, &t.TotalTx, &t.MonthRx, &t.MonthTx, &t.MonthStart,
|
||||
&t.DayRx, &t.DayTx, &dayStart, &resetDay); err != nil {
|
||||
&t.DayRx, &t.DayTx, &dayStart, &resetDay, &calUpGB, &calDownGB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = gate(t, dayStart, resetDay, now)
|
||||
out[id] = applyCalibration(gate(t, dayStart, resetDay, now), calUpGB, calDownGB)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// ProbeCoverageRow is one agent's cf-probe retirement readiness
|
||||
// (item 7 B6): whether its heartbeats carry the probe block the
|
||||
// public page is fed from. The retirement checklist in the settings
|
||||
// tab renders from these rows — the operator verifies every node is
|
||||
// covered by the in-panel probe before standing the old cf-probe
|
||||
// fleet down, node by node.
|
||||
type ProbeCoverageRow struct {
|
||||
Name string `json:"name"`
|
||||
Online bool `json:"online"`
|
||||
HasProbe bool `json:"hasProbe"`
|
||||
ProbeAt int64 `json:"probeAt"` // unix ms of the last probe-carrying heartbeat, 0 = never
|
||||
}
|
||||
|
||||
// ProbeAgentCoverage lists every agent with its probe coverage. The
|
||||
// display name follows the public view's rule (the bound node's name
|
||||
// first, the agent's own name otherwise); online is the public
|
||||
// view's 150s freshness window.
|
||||
func ProbeAgentCoverage() []ProbeCoverageRow {
|
||||
db := database.GetDB()
|
||||
var agents []model.AgentNode
|
||||
if err := db.Find(&agents).Error; err != nil {
|
||||
return []ProbeCoverageRow{}
|
||||
}
|
||||
nodeNames := map[int]string{}
|
||||
var nodes []model.Node
|
||||
if err := db.Find(&nodes).Error; err == nil {
|
||||
for _, n := range nodes {
|
||||
nodeNames[n.Id] = n.Name
|
||||
}
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
out := make([]ProbeCoverageRow, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
name := a.Name
|
||||
if a.NodeID != nil {
|
||||
if bound, ok := nodeNames[*a.NodeID]; ok && bound != "" {
|
||||
name = bound
|
||||
}
|
||||
}
|
||||
out = append(out, ProbeCoverageRow{
|
||||
Name: name,
|
||||
Online: a.LastSeen > 0 && nowMs-a.LastSeen <= 150_000,
|
||||
HasProbe: a.ProbeAt > 0,
|
||||
ProbeAt: a.ProbeAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -28,9 +29,11 @@ import (
|
||||
// 150 s absorbs one lost beat plus jitter.
|
||||
const ProbeOnlineThresholdMs = 150_000
|
||||
|
||||
// ProbeSnapshotTTL mirrors the hub's 1900 ms frame reuse: just under
|
||||
// the 2 s push interval, so a frame is built at most once per tick.
|
||||
const ProbeSnapshotTTL = 1900 * time.Millisecond
|
||||
// ProbeSnapshotTTL mirrors the hub's frame reuse, shortened in
|
||||
// V2.0.3 from the hub's 1900 ms: just under the fastest (1 s) push
|
||||
// cadence, so a 1 s stream still gets a fresh frame every tick
|
||||
// instead of repeating the previous one half the time.
|
||||
const ProbeSnapshotTTL = 900 * time.Millisecond
|
||||
|
||||
// ---- public-surface settings (main settings KV, hub key names) ----
|
||||
|
||||
@@ -56,6 +59,25 @@ func setProbeSetting(key, value string) error {
|
||||
return db.Save(&row).Error
|
||||
}
|
||||
|
||||
// ProbeFastIntervalSec is the agent fast-report cadence in seconds
|
||||
// (V2.0.1 probe batch): runtime metrics ride a lightweight report at
|
||||
// this interval instead of the 60 s heartbeat. Default 3, operator
|
||||
// range 1–60 (setting key probe_fast_interval).
|
||||
func ProbeFastIntervalSec() int {
|
||||
v, ok := probeSetting("probe_fast_interval")
|
||||
if !ok {
|
||||
return 3
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n < 1 {
|
||||
return 3
|
||||
}
|
||||
if n > 60 {
|
||||
return 60
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ProbePublicPageEnabled reports the public-page master switch. Per
|
||||
// the item-7 scope decision the page ships OFF until the operator
|
||||
// turns it on (setting public_page = "on"); the hub's own default is
|
||||
@@ -246,6 +268,19 @@ func expiresInDays(expires *string, now time.Time) any {
|
||||
return days
|
||||
}
|
||||
|
||||
// probeDisplayName is the name a node carries in the public views
|
||||
// (V2.2.1 ⑦): the operator's public remark when one is set — it is
|
||||
// the label written for public consumption — else the node name. The
|
||||
// contract field stays "name"; themes and the public API keep their
|
||||
// shape, only the value's source changes. The panel's internal node
|
||||
// list is a different surface and keeps the plain node name.
|
||||
func probeDisplayName(name, publicRemark string) string {
|
||||
if r := strings.TrimSpace(publicRemark); r != "" {
|
||||
return r
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// buildProbeNodeView renders one node for one audience. The public
|
||||
// shape is built by whitelist (hub node_view): address, hostname and
|
||||
// the private remark exist only in the admin (full) rendering.
|
||||
@@ -276,7 +311,7 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
|
||||
|
||||
view := map[string]any{
|
||||
"id": a.Id,
|
||||
"name": src.name,
|
||||
"name": probeDisplayName(src.name, m.PublicRemark),
|
||||
"country": m.CountryPin,
|
||||
"group": m.GroupName,
|
||||
"public_remark": m.PublicRemark,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
)
|
||||
|
||||
// V2.2.1 ⑦: the public views' node name is the operator's public
|
||||
// remark when one is set, and the node name otherwise — the panel's
|
||||
// own list (a different surface) is untouched.
|
||||
|
||||
func TestProbeDisplayNamePrefersPublicRemark(t *testing.T) {
|
||||
if got := probeDisplayName("node-name", "东京 01"); got != "东京 01" {
|
||||
t.Fatalf("with remark = %q, want the remark", got)
|
||||
}
|
||||
if got := probeDisplayName("node-name", ""); got != "node-name" {
|
||||
t.Fatalf("empty remark = %q, want node name", got)
|
||||
}
|
||||
if got := probeDisplayName("node-name", " "); got != "node-name" {
|
||||
t.Fatalf("whitespace-only remark = %q, want node name", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProbeNodeViewNameSource(t *testing.T) {
|
||||
withRemark := probeNodeSource{
|
||||
name: "internal-name",
|
||||
meta: probe.Meta{PublicRemark: "公开名", Public: true},
|
||||
}
|
||||
if got := buildProbeNodeView(withRemark, false)["name"]; got != "公开名" {
|
||||
t.Fatalf("anonymous view name = %v, want the public remark", got)
|
||||
}
|
||||
if got := buildProbeNodeView(withRemark, true)["name"]; got != "公开名" {
|
||||
t.Fatalf("admin view name = %v, want the public remark", got)
|
||||
}
|
||||
without := probeNodeSource{name: "internal-name", meta: probe.Meta{Public: true}}
|
||||
if got := buildProbeNodeView(without, false)["name"]; got != "internal-name" {
|
||||
t.Fatalf("view name without remark = %v, want the node name", got)
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,12 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
)
|
||||
|
||||
// Item 7 B5: the theme store (hub frontend.rs + api.rs theme
|
||||
@@ -716,13 +719,19 @@ func SetProbeIconSetting(key, value string) error {
|
||||
return setProbeSetting(key, value)
|
||||
}
|
||||
|
||||
// ProbePublicSettings is the public-surface settings block the theme
|
||||
// tab edits (settings KV keys, hub names).
|
||||
// ProbePublicSettings is the public-surface settings block the probe
|
||||
// settings page edits (settings KV keys, hub names). RetentionDays
|
||||
// and FastIntervalSec joined in V2.0.1: the retention window already
|
||||
// existed as the retention_days key (read by the probe store's
|
||||
// housekeeping), it only lacked an editing surface; the fast-report
|
||||
// interval drives the agents' lightweight metrics cadence.
|
||||
type ProbePublicSettings struct {
|
||||
PublicPage bool `json:"publicPage"`
|
||||
SiteName string `json:"siteName"`
|
||||
Site string `json:"site"`
|
||||
GitHubProxy string `json:"githubProxy"`
|
||||
RetentionDays int64 `json:"retentionDays"`
|
||||
FastIntervalSec int `json:"fastIntervalSec"`
|
||||
}
|
||||
|
||||
// GetProbePublicSettings reads the settings block.
|
||||
@@ -733,6 +742,8 @@ func GetProbePublicSettings() ProbePublicSettings {
|
||||
SiteName: ProbeSiteName(),
|
||||
Site: ProbeSiteURL(),
|
||||
GitHubProxy: proxy,
|
||||
RetentionDays: probe.RetentionDays(),
|
||||
FastIntervalSec: ProbeFastIntervalSec(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +753,12 @@ func SetProbePublicSettings(s ProbePublicSettings) error {
|
||||
if s.GitHubProxy != "" && !strings.HasPrefix(s.GitHubProxy, "https://") {
|
||||
return themeRefuse("GitHub 代理要以 https:// 开头,或者留空")
|
||||
}
|
||||
if s.RetentionDays < 1 || s.RetentionDays > 365 {
|
||||
return themeRefuse("历史保留天数要在 1 到 365 之间")
|
||||
}
|
||||
if s.FastIntervalSec < 1 || s.FastIntervalSec > 60 {
|
||||
return themeRefuse("快报间隔要在 1 到 60 秒之间")
|
||||
}
|
||||
if err := SetProbePublicPageEnabled(s.PublicPage); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -751,6 +768,12 @@ func SetProbePublicSettings(s ProbePublicSettings) error {
|
||||
if err := setProbeSetting("site", s.Site); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setProbeSetting("retention_days", strconv.FormatInt(s.RetentionDays, 10)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setProbeSetting("probe_fast_interval", strconv.Itoa(s.FastIntervalSec)); err != nil {
|
||||
return err
|
||||
}
|
||||
return setProbeSetting("github_proxy", s.GitHubProxy)
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,15 @@ func (s *SingboxService) GetSingboxConfigForAgent(nodeID int, singboxOutboundsJS
|
||||
if len(fragment) == 0 {
|
||||
continue
|
||||
}
|
||||
// V2.0.1 ⓪: the row's listen/port are authoritative at
|
||||
// generation time. The stored fragment is a snapshot (taken
|
||||
// at migration or at the last save's recompute) and drifts
|
||||
// whenever the row was edited through a path that did not
|
||||
// recompute it — V2.0.0's converter also omitted "listen"
|
||||
// entirely for an empty row listen, which sing-box reads as
|
||||
// loopback-only, silently unbinding migrated inbounds from
|
||||
// the network. The row always wins here.
|
||||
applyRowListenOverride(inbound, fragment)
|
||||
preserved := singbox.Preserved{}
|
||||
if strings.TrimSpace(inbound.SingboxPreserved) != "" {
|
||||
if err := json.Unmarshal([]byte(inbound.SingboxPreserved), &preserved); err != nil {
|
||||
@@ -140,6 +149,32 @@ func (s *SingboxService) GetSingboxConfigForAgent(nodeID int, singboxOutboundsJS
|
||||
}), nil
|
||||
}
|
||||
|
||||
// applyRowListenOverride stamps the inbound row's listen address and
|
||||
// port onto a sing-box fragment, replacing whatever the snapshot
|
||||
// carried (V2.0.1 ⓪). An empty row listen means "all interfaces" in
|
||||
// the panel's model, which sing-box spells "::" (dual-stack
|
||||
// wildcard); an absent listen field would default to loopback-only,
|
||||
// so the wildcard is always written explicitly. A specific row
|
||||
// listen (one IPv6 address, an internal address, ...) is carried
|
||||
// verbatim — sing-box then binds exactly that address. WireGuard
|
||||
// endpoints take no listen address, only the port.
|
||||
func applyRowListenOverride(inbound *model.Inbound, fragment map[string]any) {
|
||||
if fragment["type"] == "wireguard" {
|
||||
if inbound.Port > 0 {
|
||||
fragment["listen_port"] = inbound.Port
|
||||
}
|
||||
return
|
||||
}
|
||||
if inbound.Listen != "" {
|
||||
fragment["listen"] = inbound.Listen
|
||||
} else {
|
||||
fragment["listen"] = "::"
|
||||
}
|
||||
if inbound.Port > 0 {
|
||||
fragment["listen_port"] = inbound.Port
|
||||
}
|
||||
}
|
||||
|
||||
// filterEnabledClients applies the xray generation path's client
|
||||
// gates, in the same order and with the same log line: a client whose
|
||||
// traffic row is disabled (expiration or traffic limit) is dropped,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"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/singbox"
|
||||
)
|
||||
|
||||
// RecomputeSingboxFragment re-runs the sing-box converter over an
|
||||
// inbound row's current panel material and stores the fresh fragment
|
||||
// + preserved sidecar (V2.0.1 ⓪). Before this, the fragment was a
|
||||
// one-shot migration snapshot: panel edits after the migration (a new
|
||||
// Reality target, a changed listen/port, ...) never reached it, so
|
||||
// the agent kept serving the migration-day material while the panel
|
||||
// showed the edit as saved. The identity layer is read back after the
|
||||
// caller's own sync, so the fragment's embedded users match what
|
||||
// generation would project anyway; the row listen/port override is
|
||||
// stamped on top exactly as generation applies it.
|
||||
func (s *SingboxService) RecomputeSingboxFragment(inbound *model.Inbound) error {
|
||||
attached, err := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clients := toSingboxClients(attached)
|
||||
fillConverterOnlyClientFields(inbound, attached, clients)
|
||||
|
||||
fallbackMaps, err := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fallbacks := make([]json.RawMessage, 0, len(fallbackMaps))
|
||||
for _, fm := range fallbackMaps {
|
||||
raw, err := json.Marshal(fm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fallbacks = append(fallbacks, raw)
|
||||
}
|
||||
|
||||
res, err := singbox.ConvertInbound(singbox.Inbound{
|
||||
Listen: inbound.Listen,
|
||||
Port: inbound.Port,
|
||||
Protocol: string(inbound.Protocol),
|
||||
Tag: inbound.Tag,
|
||||
DisableFlow: inbound.DisableFlow,
|
||||
Settings: json.RawMessage(inbound.Settings),
|
||||
StreamSettings: json.RawMessage(inbound.StreamSettings),
|
||||
Sniffing: json.RawMessage(inbound.Sniffing),
|
||||
Fallbacks: fallbacks,
|
||||
Clients: clients,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fragJSON, err := res.MarshalFragment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fragJSON != "" {
|
||||
fragment := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(fragJSON), &fragment); err != nil {
|
||||
return err
|
||||
}
|
||||
applyRowListenOverride(inbound, fragment)
|
||||
raw, err := json.Marshal(fragment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fragJSON = string(raw)
|
||||
}
|
||||
presJSON, err := res.MarshalPreserved()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return database.GetDB().Model(&model.Inbound{}).Where("id = ?", inbound.Id).Updates(map[string]any{
|
||||
"singbox_config": fragJSON, "singbox_preserved": presJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// fillConverterOnlyClientFields restores the client fields the
|
||||
// identity projection (toSingboxClients) does not carry but the
|
||||
// converter reads: VMess alterId lives only in the settings.clients
|
||||
// mirror (the identity layer has no column for it — the B1 migrator
|
||||
// sourced it from the same mirror), and a VLESS client's reverse
|
||||
// block reaches the identity projection as a parsed tag, re-rendered
|
||||
// here in the converter's verbatim-JSON shape.
|
||||
func fillConverterOnlyClientFields(inbound *model.Inbound, attached []model.Client, clients []singbox.Client) {
|
||||
alterByEmail := map[string]int{}
|
||||
var settings struct {
|
||||
Clients []struct {
|
||||
Email string `json:"email"`
|
||||
AlterID int `json:"alterId"`
|
||||
} `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err == nil {
|
||||
for _, c := range settings.Clients {
|
||||
if c.AlterID != 0 {
|
||||
alterByEmail[c.Email] = c.AlterID
|
||||
}
|
||||
}
|
||||
}
|
||||
reverseByEmail := map[string]string{}
|
||||
for _, c := range attached {
|
||||
if c.Reverse != nil && c.Reverse.Tag != "" {
|
||||
reverseByEmail[c.Email] = c.Reverse.Tag
|
||||
}
|
||||
}
|
||||
for i := range clients {
|
||||
if alt, ok := alterByEmail[clients[i].Email]; ok {
|
||||
clients[i].AlterID = alt
|
||||
}
|
||||
if tag, ok := reverseByEmail[clients[i].Email]; ok {
|
||||
if raw, err := json.Marshal(map[string]string{"tag": tag}); err == nil {
|
||||
clients[i].Reverse = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeSingboxFragmentLogged is the save-path hook: a failed
|
||||
// recompute must not fail the panel save itself (the row is already
|
||||
// persisted and generation still overrides listen/port from it), but
|
||||
// it is logged loudly — a silent skip here is exactly how the
|
||||
// V2.0.0 fragment drift stayed invisible.
|
||||
func recomputeSingboxFragmentLogged(inbound *model.Inbound) {
|
||||
svc := &SingboxService{inboundService: InboundService{}}
|
||||
if err := svc.RecomputeSingboxFragment(inbound); err != nil {
|
||||
logger.Warningf("sing-box fragment recompute for inbound %d (%s) failed: %v", inbound.Id, inbound.Tag, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// V2.0.1 ⓪ end-to-end regression for the loopback blind spot that
|
||||
// let the V2 defect ship: a generated config is run under a real
|
||||
// sing-box binary and connected to through the machine's
|
||||
// NON-loopback address, not just 127.0.0.1 (the B5 drill connected
|
||||
// loopback-only and never saw that an absent listen field binds
|
||||
// loopback in sing-box). The negative control — the same config
|
||||
// with the listen key removed, the pre-fix shape — must come up
|
||||
// bound to loopback in the core's own start line, proving the test
|
||||
// can actually catch the defect.
|
||||
//
|
||||
// Bind scope is asserted from the core's "tcp server started at"
|
||||
// log line: this sandbox delivers connections to any local address
|
||||
// (loopback-bound python http.server included) when dialing the
|
||||
// machine's own IP, so a dial alone cannot discriminate bind scope
|
||||
// here — on a real node it does, which is exactly how the defect
|
||||
// presented in production (external clients timed out while ss
|
||||
// showed the truth). The dials remain as reachability smoke for
|
||||
// both addresses.
|
||||
//
|
||||
// Gated on SINGBOX_E2E_BIN pointing at a sing-box binary (the
|
||||
// self-built v1.13.21 release build in production); skipped
|
||||
// otherwise so the unit suite stays hermetic.
|
||||
func TestSingboxListenNonLoopbackE2E(t *testing.T) {
|
||||
bin := os.Getenv("SINGBOX_E2E_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("SINGBOX_E2E_BIN not set")
|
||||
}
|
||||
lanIP := firstNonLoopbackIPv4(t)
|
||||
|
||||
setupBulkDB(t)
|
||||
nodeID := 43
|
||||
node := &model.Node{Id: nodeID, Name: "e2e-node", Address: "192.0.2.1", Port: 8443, Enable: true, Status: "offline"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
clients := []model.Client{{Email: "e2e@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "se", Enable: true}}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "listen-e2e",
|
||||
Enable: true,
|
||||
Protocol: model.VLESS,
|
||||
Port: 29871,
|
||||
Listen: "",
|
||||
Tag: "inbound-29871",
|
||||
NodeID: &nodeID,
|
||||
Settings: clientsSettings(t, clients),
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatalf("seed client linkage: %v", err)
|
||||
}
|
||||
|
||||
// The V2.0.1 save path computes the fragment; generation then
|
||||
// serves it. This is the exact chain a panel edit travels.
|
||||
singboxSvc := &SingboxService{}
|
||||
if err := singboxSvc.RecomputeSingboxFragment(inbound); err != nil {
|
||||
t.Fatalf("RecomputeSingboxFragment: %v", err)
|
||||
}
|
||||
cfg, err := singboxSvc.GetSingboxConfigForAgent(nodeID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSingboxConfigForAgent: %v", err)
|
||||
}
|
||||
frag := firstInboundFrag(t, cfg)
|
||||
if frag["listen"] != "::" {
|
||||
t.Fatalf("generated listen = %v, want ::", frag["listen"])
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
runAndDial := func(t *testing.T, name string, cfg map[string]any, port int) (loopOK, lanOK bool, startedAt string) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, fmt.Sprintf("cfg-%s.json", name))
|
||||
if err := os.WriteFile(cfgPath, raw, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
if out, err := exec.Command(bin, "check", "-c", cfgPath).CombinedOutput(); err != nil {
|
||||
t.Fatalf("sing-box check: %v\n%s", err, out)
|
||||
}
|
||||
logPath := filepath.Join(dir, fmt.Sprintf("run-%s.log", name))
|
||||
logFile, _ := os.Create(logPath)
|
||||
cmd := exec.Command(bin, "run", "-c", cfgPath)
|
||||
cmd.Stdout, cmd.Stderr = logFile, logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start sing-box: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
_ = logFile.Close()
|
||||
}()
|
||||
dial := func(addr string) bool {
|
||||
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
loopAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
lanAddr := fmt.Sprintf("%s:%d", lanIP, port)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
loopOK = dial(loopAddr)
|
||||
lanOK = dial(lanAddr)
|
||||
if loopOK && lanOK {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
// The core's own start line is the bind-scope evidence.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
logBytes, _ := os.ReadFile(logPath)
|
||||
for _, line := range strings.Split(string(logBytes), "\n") {
|
||||
if strings.Contains(line, "tcp server started at") {
|
||||
startedAt = strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
return loopOK, lanOK, startedAt
|
||||
}
|
||||
|
||||
// Positive: the generated config answers on both addresses and
|
||||
// the core reports a wildcard bind.
|
||||
loopOK, lanOK, startedAt := runAndDial(t, "gen", cfg, 29871)
|
||||
if !loopOK {
|
||||
t.Error("generated config does not answer on 127.0.0.1:29871")
|
||||
}
|
||||
if !lanOK {
|
||||
t.Errorf("generated config does not answer on the non-loopback address %s:29871", lanIP)
|
||||
}
|
||||
if !strings.Contains(startedAt, "tcp server started at [::]:29871") {
|
||||
t.Errorf("generated config bind line = %q, want the core started at [::]:29871", startedAt)
|
||||
}
|
||||
|
||||
// Negative control: strip the listen key (the pre-fix fragment
|
||||
// shape). The core must report a loopback bind — the exact
|
||||
// production defect — while the fixed config above does not.
|
||||
raw, _ := json.Marshal(cfg)
|
||||
var control map[string]any
|
||||
if err := json.Unmarshal(raw, &control); err != nil {
|
||||
t.Fatalf("clone config: %v", err)
|
||||
}
|
||||
delete(firstInboundFrag(t, control), "listen")
|
||||
loopOK, _, startedAt = runAndDial(t, "ctl", control, 29871)
|
||||
if !loopOK {
|
||||
t.Error("control config lost loopback too — the negative control is not isolating the listen behaviour")
|
||||
}
|
||||
if !strings.Contains(startedAt, "tcp server started at 127.0.0.1:29871") {
|
||||
t.Errorf("control bind line = %q, want the core started at 127.0.0.1:29871 (the pre-fix loopback default)", startedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func firstInboundFrag(t *testing.T, cfg map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
inbounds, _ := cfg["inbounds"].([]any)
|
||||
if len(inbounds) == 0 {
|
||||
t.Fatalf("generated config has no inbounds: %v", cfg)
|
||||
}
|
||||
frag, _ := inbounds[0].(map[string]any)
|
||||
if frag == nil {
|
||||
t.Fatalf("generated inbound is not an object: %v", inbounds[0])
|
||||
}
|
||||
return frag
|
||||
}
|
||||
|
||||
func firstNonLoopbackIPv4(t *testing.T) string {
|
||||
t.Helper()
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
t.Fatalf("interface addrs: %v", err)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipNet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip := ipNet.IP.To4()
|
||||
if ip != nil && !ip.IsLoopback() {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
t.Skip("no non-loopback IPv4 on this machine")
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// V2.0.1 ⓪: the row's listen/port rule the generated sing-box config.
|
||||
// These tests pin the override function itself, the generation-time
|
||||
// application over a stale stored fragment, and the save-time
|
||||
// fragment recompute wired into UpdateInbound.
|
||||
|
||||
func TestApplyRowListenOverride(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
row *model.Inbound
|
||||
fragment map[string]any
|
||||
wantListen any // nil = key must be absent
|
||||
wantPort float64
|
||||
}{
|
||||
{
|
||||
name: "empty row listen binds the dual-stack wildcard",
|
||||
row: &model.Inbound{Listen: "", Port: 8443},
|
||||
fragment: map[string]any{"type": "vless", "tag": "in-1"},
|
||||
wantListen: "::",
|
||||
wantPort: 8443,
|
||||
},
|
||||
{
|
||||
name: "specific IPv4 listen is kept verbatim",
|
||||
row: &model.Inbound{Listen: "192.0.2.10", Port: 443},
|
||||
fragment: map[string]any{"type": "vless", "tag": "in-1", "listen": "0.0.0.0"},
|
||||
wantListen: "192.0.2.10",
|
||||
wantPort: 443,
|
||||
},
|
||||
{
|
||||
name: "specific IPv6 listen is kept verbatim",
|
||||
row: &model.Inbound{Listen: "2001:db8::5", Port: 443},
|
||||
fragment: map[string]any{"type": "shadowsocks", "tag": "in-1"},
|
||||
wantListen: "2001:db8::5",
|
||||
wantPort: 443,
|
||||
},
|
||||
{
|
||||
name: "row port overrides a stale fragment port",
|
||||
row: &model.Inbound{Listen: "", Port: 9999},
|
||||
fragment: map[string]any{"type": "vless", "tag": "in-1", "listen_port": float64(1111)},
|
||||
wantListen: "::",
|
||||
wantPort: 9999,
|
||||
},
|
||||
{
|
||||
name: "wireguard endpoint gains no listen key, port only",
|
||||
row: &model.Inbound{Listen: "192.0.2.10", Port: 51820},
|
||||
fragment: map[string]any{"type": "wireguard", "tag": "in-1"},
|
||||
wantListen: nil,
|
||||
wantPort: 51820,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
applyRowListenOverride(tc.row, tc.fragment)
|
||||
got, present := tc.fragment["listen"]
|
||||
if tc.wantListen == nil {
|
||||
if present {
|
||||
t.Errorf("listen = %v, want the key absent", got)
|
||||
}
|
||||
} else if !present || got != tc.wantListen {
|
||||
t.Errorf("listen = %v (present %v), want %v", got, present, tc.wantListen)
|
||||
}
|
||||
if p := numOf(tc.fragment["listen_port"]); p != tc.wantPort {
|
||||
t.Errorf("listen_port = %v, want %v", tc.fragment["listen_port"], tc.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSingboxGenerationAppliesRowListenOverStaleFragment is the
|
||||
// regression shape of the V2 defect itself: a stored fragment
|
||||
// without a listen key (the migration-era snapshot) must reach the
|
||||
// agent bound to the wildcard, on the row's current port — not
|
||||
// sing-box's loopback default on a stale port.
|
||||
func TestSingboxGenerationAppliesRowListenOverStaleFragment(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
nodeID := 41
|
||||
inbound := &model.Inbound{
|
||||
Remark: "listen-regression",
|
||||
Enable: true,
|
||||
Protocol: model.VLESS,
|
||||
Port: 47158,
|
||||
Tag: "inbound-47158",
|
||||
NodeID: &nodeID,
|
||||
// Migration-era snapshot: no listen key, stale port.
|
||||
SingboxConfig: `{"type":"vless","tag":"inbound-47158","listen_port":1111,` +
|
||||
`"users":[{"name":"a@example.com","uuid":"00000000-0000-0000-0000-000000000001"}]}`,
|
||||
}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
svc := &SingboxService{}
|
||||
cfg, err := svc.GetSingboxConfigForAgent(nodeID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSingboxConfigForAgent: %v", err)
|
||||
}
|
||||
inbounds, _ := cfg["inbounds"].([]any)
|
||||
if len(inbounds) != 1 {
|
||||
t.Fatalf("generated inbounds = %d, want 1", len(inbounds))
|
||||
}
|
||||
frag, _ := inbounds[0].(map[string]any)
|
||||
if frag["listen"] != "::" {
|
||||
t.Errorf("generated listen = %v, want \"::\" (an absent listen binds loopback only in sing-box)", frag["listen"])
|
||||
}
|
||||
if numOf(frag["listen_port"]) != 47158 {
|
||||
t.Errorf("generated listen_port = %v, want the row port 47158", frag["listen_port"])
|
||||
}
|
||||
}
|
||||
|
||||
// numOf reads a JSON-ish numeric (int / float64 / json.Number)
|
||||
// without caring which shape the producer used.
|
||||
func numOf(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
case json.Number:
|
||||
f, _ := n.Float64()
|
||||
return f
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TestRecomputeSingboxFragmentOnUpdate drives the save path: a row
|
||||
// edited in the panel (listen + port changed) must have its stored
|
||||
// fragment recomputed so the next agent poll serves the new binding.
|
||||
func TestRecomputeSingboxFragmentOnUpdate(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
nodeID := 42
|
||||
node := &model.Node{Id: nodeID, Name: "listen-node", Address: "192.0.2.1", Port: 8443, Enable: true, Status: "offline"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
clients := []model.Client{{Email: "u@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "s1", Enable: true}}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "recompute",
|
||||
Enable: true,
|
||||
Protocol: model.VLESS,
|
||||
Port: 23456,
|
||||
Listen: "",
|
||||
Tag: "inbound-23456",
|
||||
NodeID: &nodeID,
|
||||
Settings: clientsSettings(t, clients),
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
// The identity layer the converter reads is the client linkage,
|
||||
// seeded the way the client flows seed it.
|
||||
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatalf("seed client linkage: %v", err)
|
||||
}
|
||||
|
||||
singboxSvc := &SingboxService{}
|
||||
if err := singboxSvc.RecomputeSingboxFragment(inbound); err != nil {
|
||||
t.Fatalf("RecomputeSingboxFragment: %v", err)
|
||||
}
|
||||
frag := storedFragment(t, inbound.Id)
|
||||
if frag["listen"] != "::" || numOf(frag["listen_port"]) != 23456 {
|
||||
t.Fatalf("fragment after first recompute: listen=%v port=%v, want ::/23456", frag["listen"], frag["listen_port"])
|
||||
}
|
||||
if users, _ := frag["users"].([]any); len(users) != 1 {
|
||||
t.Fatalf("fragment users = %v, want the row's one client", frag["users"])
|
||||
}
|
||||
|
||||
// Panel edit: bind a specific address and move the port.
|
||||
edited := *inbound
|
||||
edited.Listen = "192.0.2.10"
|
||||
edited.Port = 23457
|
||||
if _, _, err := (&InboundService{}).UpdateInbound(&edited); err != nil {
|
||||
t.Fatalf("UpdateInbound: %v", err)
|
||||
}
|
||||
frag = storedFragment(t, inbound.Id)
|
||||
if frag["listen"] != "192.0.2.10" || numOf(frag["listen_port"]) != 23457 {
|
||||
t.Errorf("fragment after update: listen=%v port=%v, want 192.0.2.10/23457", frag["listen"], frag["listen_port"])
|
||||
}
|
||||
}
|
||||
|
||||
func storedFragment(t *testing.T, inboundID int) map[string]any {
|
||||
t.Helper()
|
||||
var row model.Inbound
|
||||
if err := database.GetDB().First(&row, inboundID).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
if row.SingboxConfig == "" {
|
||||
t.Fatalf("stored singbox_config is empty")
|
||||
}
|
||||
frag := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(row.SingboxConfig), &frag); err != nil {
|
||||
t.Fatalf("unmarshal stored fragment: %v", err)
|
||||
}
|
||||
return frag
|
||||
}
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "مدة التشغيل",
|
||||
"latency": "الكمون",
|
||||
"lastHeartbeat": "آخر نبضة",
|
||||
"xrayVersion": "إصدار Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "إصدار اللوحة",
|
||||
"actions": "العمليات",
|
||||
"probe": "فحص فوري",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "افتراضي",
|
||||
"title": "إعدادات البانل",
|
||||
"save": "حفظ",
|
||||
"infoDesc": "كل تغيير هتعمله هنا لازم يتخزن. ياريت تعيد تشغيل البانل عشان التعديلات تتفعل.",
|
||||
"restartPanel": "إعادة تشغيل اللوحة",
|
||||
"restartPanelDesc": "متأكد إنك عايز تعيد تشغيل البانل؟ لو ماقدرتش تدخل بعد إعادة التشغيل، شوف سجل البانل على السيرفر.",
|
||||
"restartPanelSuccess": "تم إعادة تشغيل اللوحة بنجاح",
|
||||
|
||||
@@ -974,7 +974,7 @@
|
||||
"uptime": "Uptime",
|
||||
"latency": "Latency",
|
||||
"lastHeartbeat": "Last Heartbeat",
|
||||
"xrayVersion": "Xray Version",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Panel Version",
|
||||
"actions": "Actions",
|
||||
"probe": "Probe Now",
|
||||
@@ -1051,6 +1051,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -1089,6 +1091,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1118,7 +1126,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1152,6 +1166,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1175,23 +1198,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "Default",
|
||||
"title": "Panel Settings",
|
||||
"save": "Save",
|
||||
"infoDesc": "Every change made here needs to be saved. Please restart the panel to apply changes.",
|
||||
"restartPanel": "Restart Panel",
|
||||
"restartPanelDesc": "Are you sure you want to restart the panel? If you cannot access the panel after restarting, please view the panel log info on the server.",
|
||||
"restartPanelSuccess": "The panel was successfully restarted.",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "Tiempo activo",
|
||||
"latency": "Latencia",
|
||||
"lastHeartbeat": "Último latido",
|
||||
"xrayVersion": "Versión de Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Versión del panel",
|
||||
"actions": "Acciones",
|
||||
"probe": "Sondear ahora",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "Predeterminado",
|
||||
"title": "Configuraciones",
|
||||
"save": "Guardar",
|
||||
"infoDesc": "Cada cambio realizado aquí debe ser guardado. Por favor, reinicie el panel para aplicar los cambios.",
|
||||
"restartPanel": "Reiniciar panel",
|
||||
"restartPanelDesc": "¿Está seguro de que desea reiniciar el panel? Haga clic en Aceptar para reiniciar después de 3 segundos. Si no puede acceder al panel después de reiniciar, por favor, consulte la información de registro del panel en el servidor.",
|
||||
"restartPanelSuccess": "El panel se reinició correctamente",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "مدت فعالیت",
|
||||
"latency": "تاخیر",
|
||||
"lastHeartbeat": "آخرین ضربان",
|
||||
"xrayVersion": "نسخه Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "نسخه پنل",
|
||||
"actions": "عملیات",
|
||||
"probe": "بررسی فوری",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "پیشفرض",
|
||||
"title": "تنظیمات پنل",
|
||||
"save": "ذخیره",
|
||||
"infoDesc": "برای اعمال تغییرات در این بخش باید پس از ذخیره کردن، پنل را ریستارت کنید",
|
||||
"restartPanel": "راهاندازی مجدد پنل",
|
||||
"restartPanelDesc": "آیا مطمئن به ریستارت پنل هستید؟ اگر پساز ریستارت نمیتوانید به پنل دسترسی پیدا کنید، لطفاً گزارشهای موجود در اسکریپت پنل را بررسی کنید",
|
||||
"restartPanelSuccess": "پنل با موفقیت راهاندازی مجدد شد",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "Uptime",
|
||||
"latency": "Latensi",
|
||||
"lastHeartbeat": "Heartbeat Terakhir",
|
||||
"xrayVersion": "Versi Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Versi panel",
|
||||
"actions": "Aksi",
|
||||
"probe": "Probe Sekarang",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "Bawaan",
|
||||
"title": "Pengaturan Panel",
|
||||
"save": "Simpan",
|
||||
"infoDesc": "Setiap perubahan yang dibuat di sini perlu disimpan. Harap restart panel untuk menerapkan perubahan.",
|
||||
"restartPanel": "Mulai ulang panel",
|
||||
"restartPanelDesc": "Apakah Anda yakin ingin merestart panel? Jika Anda tidak dapat mengakses panel setelah merestart, lihat info log panel di server.",
|
||||
"restartPanelSuccess": "Panel berhasil dimulai ulang",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "稼働時間",
|
||||
"latency": "レイテンシ",
|
||||
"lastHeartbeat": "最後のハートビート",
|
||||
"xrayVersion": "Xrayバージョン",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "パネルのバージョン",
|
||||
"actions": "操作",
|
||||
"probe": "今すぐプローブ",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "デフォルト",
|
||||
"title": "パネル設定",
|
||||
"save": "保存",
|
||||
"infoDesc": "ここでのすべての変更は、保存してパネルを再起動する必要があります",
|
||||
"restartPanel": "パネルを再起動",
|
||||
"restartPanelDesc": "パネルを再起動してもよろしいですか?再起動後にパネルにアクセスできない場合は、サーバーでパネルログを確認してください",
|
||||
"restartPanelSuccess": "パネルの再起動に成功しました",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "Tempo ativo",
|
||||
"latency": "Latência",
|
||||
"lastHeartbeat": "Último heartbeat",
|
||||
"xrayVersion": "Versão do Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Versão do painel",
|
||||
"actions": "Ações",
|
||||
"probe": "Sondar agora",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "Padrão",
|
||||
"title": "Configurações do Painel",
|
||||
"save": "Salvar",
|
||||
"infoDesc": "Toda alteração feita aqui precisa ser salva. Reinicie o painel para aplicar as alterações.",
|
||||
"restartPanel": "Reiniciar painel",
|
||||
"restartPanelDesc": "Tem certeza de que deseja reiniciar o painel? Se não conseguir acessar o painel após reiniciar, consulte os logs do painel no servidor.",
|
||||
"restartPanelSuccess": "O painel foi reiniciado com sucesso",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "Аптайм",
|
||||
"latency": "Задержка",
|
||||
"lastHeartbeat": "Последний пинг",
|
||||
"xrayVersion": "Версия Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Версия панели",
|
||||
"actions": "Действия",
|
||||
"probe": "Проверить сейчас",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "По умолчанию",
|
||||
"title": "Настройки",
|
||||
"save": "Сохранить",
|
||||
"infoDesc": "Сохраните изменения и перезапустите панель для их применения.",
|
||||
"restartPanel": "Перезапустить панель",
|
||||
"restartPanelDesc": "Вы уверены, что хотите перезапустить панель? Подтвердите, и перезапуск произойдёт через 3 секунды. Если панель будет недоступна, проверьте лог сервера",
|
||||
"restartPanelSuccess": "Панель успешно перезапущена",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "Çalışma Süresi",
|
||||
"latency": "Gecikme",
|
||||
"lastHeartbeat": "Son Sinyal",
|
||||
"xrayVersion": "Xray Sürümü",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Panel Sürümü",
|
||||
"actions": "İşlemler",
|
||||
"probe": "Şimdi Test Et",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "Varsayılan",
|
||||
"title": "Panel Ayarları",
|
||||
"save": "Kaydet",
|
||||
"infoDesc": "Burada yapılan her değişikliğin kaydedilmesi gerekir. Değişikliklerin uygulanması için paneli yeniden başlatın.",
|
||||
"restartPanel": "Paneli Yeniden Başlat",
|
||||
"restartPanelDesc": "Paneli yeniden başlatmak istediğinizden emin misiniz? Yeniden başlattıktan sonra panele erişemezseniz, sunucudaki panel loglarını kontrol edin.",
|
||||
"restartPanelSuccess": "Panel başarıyla yeniden başlatıldı.",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "Час роботи",
|
||||
"latency": "Затримка",
|
||||
"lastHeartbeat": "Останній пінг",
|
||||
"xrayVersion": "Версія Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Версія панелі",
|
||||
"actions": "Дії",
|
||||
"probe": "Перевірити зараз",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "Типово",
|
||||
"title": "Параметри панелі",
|
||||
"save": "Зберегти",
|
||||
"infoDesc": "Кожна внесена тут зміна повинна бути збережена. Перезапустіть панель, щоб застосувати зміни.",
|
||||
"restartPanel": "Перезапустити панель",
|
||||
"restartPanelDesc": "Ви впевнені, що бажаєте перезапустити панель? Якщо ви не можете отримати доступ до панелі після перезапуску, будь ласка, перегляньте інформацію журналу панелі на сервері.",
|
||||
"restartPanelSuccess": "Панель успішно перезапущено",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "Thời gian hoạt động",
|
||||
"latency": "Độ trễ",
|
||||
"lastHeartbeat": "Heartbeat gần nhất",
|
||||
"xrayVersion": "Phiên bản Xray",
|
||||
"coreVersion": "Core version",
|
||||
"panelVersion": "Phiên bản panel",
|
||||
"actions": "Hành động",
|
||||
"probe": "Kiểm tra ngay",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "Uplink correction (GB)",
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "Mặc định",
|
||||
"title": "Cài đặt",
|
||||
"save": "Lưu",
|
||||
"infoDesc": "Mọi thay đổi được thực hiện ở đây cần phải được lưu. Vui lòng khởi động lại bảng điều khiển để áp dụng các thay đổi.",
|
||||
"restartPanel": "Khởi động lại bảng điều khiển",
|
||||
"restartPanelDesc": "Bạn có chắc chắn muốn khởi động lại bảng điều khiển? Nhấn OK để khởi động lại sau 3 giây. Nếu bạn không thể truy cập bảng điều khiển sau khi khởi động lại, vui lòng xem thông tin nhật ký của bảng điều khiển trên máy chủ.",
|
||||
"restartPanelSuccess": "Đã khởi động lại bảng điều khiển thành công",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "运行时长",
|
||||
"latency": "延迟",
|
||||
"lastHeartbeat": "上次心跳",
|
||||
"xrayVersion": "Xray 版本",
|
||||
"coreVersion": "核心版本",
|
||||
"panelVersion": "面板版本",
|
||||
"actions": "操作",
|
||||
"probe": "立即探测",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "传统面板节点",
|
||||
"nodeTypeAgent": "Agent 轻量节点",
|
||||
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
|
||||
"probeOnly": "纯探针",
|
||||
"probeOnlyHint": "纯探针模式:该节点不运行任何代理核心,只保留探针功能(心跳、指标与延迟探测)。",
|
||||
"agentTokenTitle": "Agent 节点安装",
|
||||
"agentTokenCreated": "节点 {{name}} 创建成功",
|
||||
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "国家锁定(如 JP)",
|
||||
"ipv4Pin": "IPv4 锁定",
|
||||
"ipv6Pin": "IPv6 锁定",
|
||||
"calUp": "上行校正(GB)",
|
||||
"calDown": "下行校正(GB)",
|
||||
"calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。",
|
||||
"statIface": "统计网卡",
|
||||
"movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。",
|
||||
"goSettings": "前往探针设置",
|
||||
"save": "保存状态页信息",
|
||||
"saved": "状态页信息已保存",
|
||||
"saveFailed": "状态页信息保存失败"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "探测任务已保存",
|
||||
"deleted": "探测任务已删除",
|
||||
"loadFailed": "探测任务加载失败",
|
||||
"saveFailed": "操作失败"
|
||||
"saveFailed": "操作失败",
|
||||
"batchTitle": "批量添加",
|
||||
"batchPlaceholder": "每行一个目标,或用逗号分隔——主机:端口,如 1.1.1.1:443",
|
||||
"batchSubmit": "全部添加",
|
||||
"batchHint": "共用字段对每个目标生效;名称留空时按目标自动命名。",
|
||||
"batchEmpty": "至少填写一个目标",
|
||||
"batchDone": "已添加 {{count}} 个探测任务"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "已是最新版本",
|
||||
"config": "主题设置"
|
||||
},
|
||||
"probe": {
|
||||
"title": "探针设置",
|
||||
"retentionDays": "历史保留(天)",
|
||||
"retentionHint": "探针历史数据(指标/延迟)的保留时长。",
|
||||
"fastInterval": "快报间隔",
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。",
|
||||
"metaTitle": "按节点探针元数据",
|
||||
"metaNodePlaceholder": "选择节点"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "通知",
|
||||
"notifyHint": "节点掉线、流量超限与到期提醒,发送到下方配置的渠道(Telegram 复用面板机器人设置)。",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "清除",
|
||||
"templateHint": "可用占位符:{{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "发送测试通知",
|
||||
"testSent": "测试通知已发送:{{channels}}",
|
||||
"retireTitle": "cf-probe 退役前提",
|
||||
"retireHint": "全部节点都由面板供数后,旧公开页才可按部署文档顺序切换/下线。",
|
||||
"retirePublicPage": "公开页",
|
||||
"retirePublicPageOff": "未开启(在上方开启)",
|
||||
"retireHasProbe": "探针已上报",
|
||||
"retireNoProbe": "探针未上报",
|
||||
"retireOnline": "在线",
|
||||
"retireOffline": "离线",
|
||||
"retireProbeAt": "最近上报 {{time}}",
|
||||
"retireAllReady": "全部节点已由主控供数,可按文档顺序退役 cf-probe。",
|
||||
"retireNotReady": "仍有节点未供数,先不要下线 cf-probe。"
|
||||
"testSent": "测试通知已发送:{{channels}}"
|
||||
},
|
||||
"defaultTag": "默认",
|
||||
"title": "面板设置",
|
||||
"save": "保存",
|
||||
"infoDesc": "此处的所有更改都需要保存并重启面板才能生效",
|
||||
"restartPanel": "重启面板",
|
||||
"restartPanelDesc": "确定要重启面板吗?若重启后无法访问面板,请前往服务器查看面板日志信息",
|
||||
"restartPanelSuccess": "面板已成功重启",
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
"uptime": "運行時間",
|
||||
"latency": "延遲",
|
||||
"lastHeartbeat": "上次心跳",
|
||||
"xrayVersion": "Xray 版本",
|
||||
"coreVersion": "核心版本",
|
||||
"panelVersion": "面板版本",
|
||||
"actions": "操作",
|
||||
"probe": "立即探測",
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"probeOnly": "Probe only",
|
||||
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
@@ -971,6 +973,12 @@
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"calUp": "上行校正(GB)",
|
||||
"calDown": "下行校正(GB)",
|
||||
"calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。",
|
||||
"statIface": "统计网卡",
|
||||
"movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。",
|
||||
"goSettings": "前往探针设置",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1000,7 +1008,13 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed"
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "批量添加",
|
||||
"batchPlaceholder": "每行一个目标,或用逗号分隔——主机:端口,如 1.1.1.1:443",
|
||||
"batchSubmit": "全部添加",
|
||||
"batchHint": "共用字段对每个目标生效;名称留空时按目标自动命名。",
|
||||
"batchEmpty": "至少填写一个目标",
|
||||
"batchDone": "已添加 {{count}} 个探测任务"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1034,6 +1048,15 @@
|
||||
"alreadyLatest": "Already the latest version",
|
||||
"config": "Theme settings"
|
||||
},
|
||||
"probe": {
|
||||
"title": "探针设置",
|
||||
"retentionDays": "历史保留(天)",
|
||||
"retentionHint": "探针历史数据(指标/延迟)的保留时长。",
|
||||
"fastInterval": "快报间隔",
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。",
|
||||
"metaTitle": "按节点探针元数据",
|
||||
"metaNodePlaceholder": "选择节点"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
"notifyHint": "Offline, traffic and expiry alerts, delivered to the channels below (Telegram reuses the panel bot settings).",
|
||||
@@ -1057,23 +1080,11 @@
|
||||
"clearSecret": "Clear",
|
||||
"templateHint": "Placeholders: {{title}} {{message}} {{node}} {{event}} {{time}} {{count}} {{site}}",
|
||||
"testSend": "Send a test alert",
|
||||
"testSent": "Test alert sent: {{channels}}",
|
||||
"retireTitle": "cf-probe retirement checklist",
|
||||
"retireHint": "Only once every node is served by the panel may the old public page be switched off, in the order given in the deployment notes.",
|
||||
"retirePublicPage": "Public page",
|
||||
"retirePublicPageOff": "Off (enable it above)",
|
||||
"retireHasProbe": "Probe reporting",
|
||||
"retireNoProbe": "No probe data",
|
||||
"retireOnline": "Online",
|
||||
"retireOffline": "Offline",
|
||||
"retireProbeAt": "Last report {{time}}",
|
||||
"retireAllReady": "Every node is served by the panel; cf-probe can be retired in the documented order.",
|
||||
"retireNotReady": "Some nodes have no probe data yet; do not take cf-probe down."
|
||||
"testSent": "Test alert sent: {{channels}}"
|
||||
},
|
||||
"defaultTag": "預設",
|
||||
"title": "面板設定",
|
||||
"save": "儲存",
|
||||
"infoDesc": "此處的所有更改都需要儲存並重啟面板才能生效",
|
||||
"restartPanel": "重新啟動面板",
|
||||
"restartPanelDesc": "確定要重啟面板嗎?若重啟後無法訪問面板,請前往伺服器檢視面板日誌資訊",
|
||||
"restartPanelSuccess": "面板已成功重新啟動",
|
||||
|
||||
Reference in New Issue
Block a user