10 Commits
Author SHA1 Message Date
Muse a25c51003e install-agent: RELEASE_BASE -> V2.2.0 (sh + ps1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 13:30:56 +00:00
Muse b27bcfcb5b merge feat/v210-probeonly-win: windows probe agent + version display + reality sni fix (V2.2.0) 2026-10-23 13:30:40 +00:00
Muse 75f10b27fa windows probe-only agent + agent version display unification + reality sni from sing-box fragment
Scope B (Windows agent):
- probe.go split into shared core + probe_linux.go (verbatim Linux
  sampler, build-tagged) + probe_windows.go (Win32 sampler: GetSystemTimes
  CPU diff, GlobalMemoryStatusEx, fixed-drive df totals, GetIfTable2
  counted set, MachineGuid boot identity, registry facts; load and
  TCP/UDP counts stay zero - x/sys v0.47.0 exports no wrapper)
- update intents are acknowledged and skipped on Windows (the release
  assets are Linux binaries; Windows nodes update via install-agent.ps1)
- cmd/xui-agent-windows: SCM service host (svc.Run + mgr install/
  uninstall/start/stop) mirroring main.go's agent lifecycle wiring
- install-agent.ps1: downloads the exe, writes probeOnly agent.json,
  registers and starts the service, rerun-safe

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

Scope D (production fix):
- share-link reality sni: when the inbound has a sing-box fragment,
  sni is the fragment's tls.server_name - the only name the sing-box
  REALITY server serves. The previous rule picked a uniformly random
  serverNames entry per generation, which xray tolerated but sing-box
  refuses as an invalid connection. sid keeps its random pick: the
  converter carries the whole shortIds list into the fragment, so
  every listed sid is served.
2026-10-23 13:22:51 +00:00
Muse 7df3a338ef install-agent: RELEASE_BASE -> V2.1.0
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 19:57:56 +08:00
Rogue 5ca22ec340 probe-only node mode: master flag + agent gate + panel switch (V2.1.0 A)
agent_nodes gains probe_only; getConfig always serves the flag and withholds the sing-box payload (null config, empty hash) for flagged nodes while probe tasks, fast interval, stat iface and update intents ride as usual. The agent gates both core branches when the local agent.json preset or the master flag is on, retires a running core once (best-effort tail flush, switchover bookkeeping untouched so turning the flag off self-heals through the existing hash-current-but-not-running path), and makes the traffic cycle an explicit no-op. Panel: node create/update accept probeOnly, the node view overlays it from the binding, the node form gains a switch (carried into the generated install command), the list shows a probe-only tag in the core-version cell, and install-agent.sh --probe-only writes the preset, skips the core download/self-check and inherits the flag on no-arg reruns.
2026-10-23 11:54:35 +00:00
Muse 2d37a6de18 install-agent: RELEASE_BASE -> V2.0.3
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-23 18:45:22 +08:00
Muse db6f666ff7 probe: fast report stamps last-seen; snapshot TTL 900ms (V2.0.3)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 18:38:56 +08:00
Muse d4d259fa58 probe ws: frame cadence follows probe_fast_interval (V2.0.2)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 18:15:05 +08:00
Muse 9caa4e0cf9 install-agent: RELEASE_BASE -> V2.0.1
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-23 16:13:25 +08:00
Rogue 29de9761c8 V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00
84 changed files with 5788 additions and 1648 deletions
+270
View File
@@ -0,0 +1,270 @@
//go:build windows
// Command xui-agent-windows is the standalone Windows build of the
// 3x-ui node agent. Windows nodes are probe-only by design: the agent
// reports host facts/metrics and runs latency probes, but no xray or
// sing-box core is ever fetched or started here (the agent core
// machinery stays the master's Linux fleet concern; the local
// agent.json carries "probeOnly": true, written by
// install-agent.ps1).
//
// Usage:
//
// xui-agent-windows.exe [-config C:\x-ui\agent.json] run (service or console)
// xui-agent-windows.exe install [-config <path>] register the service
// xui-agent-windows.exe uninstall remove the service
// xui-agent-windows.exe start | stop control the service
//
// The agent lifecycle wiring (config load, baseline derivation,
// logger init, graceful stop) mirrors main.go's agent branch — the
// same internal/agent package does the work either way.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/agent"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/op/go-logging"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
const (
serviceName = "x-ui-agent"
defaultCfgPath = `C:\x-ui\agent.json`
)
func main() {
log.SetPrefix("[x-ui-agent] ")
args := os.Args[1:]
// Management subcommands come first; anything else is a run.
if len(args) > 0 {
switch args[0] {
case "install", "uninstall", "start", "stop":
cfgPath := parseConfigFlag("xui-agent-windows "+args[0], args[1:])
if err := runControl(args[0], cfgPath); err != nil {
log.Fatalf("%s: %v", args[0], err)
}
return
}
}
cfgPath := parseConfigFlag("xui-agent-windows", args)
isService, err := svc.IsWindowsService()
if err != nil {
log.Fatalf("detect service environment: %v", err)
}
if isService {
if err := svc.Run(serviceName, &agentHandler{cfgPath: cfgPath}); err != nil {
log.Fatalf("service run: %v", err)
}
return
}
// Interactive run: console foreground, Ctrl+C stops gracefully.
running, err := startAgent(cfgPath)
if err != nil {
log.Fatalf("start agent: %v", err)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
select {
case <-sigCh:
log.Println("Shutting down agent...")
running.Stop()
<-running.done
case err := <-running.done:
if err != nil {
log.Fatalf("Agent error: %v", err)
}
}
}
func parseConfigFlag(name string, args []string) string {
fs := flag.NewFlagSet(name, flag.ExitOnError)
cfg := fs.String("config", defaultCfgPath, "Path to agent config file")
_ = fs.Parse(args)
return *cfg
}
// runningAgent is one started agent plus its completion signal.
type runningAgent struct {
ag *agent.Agent
done chan error
}
func (r *runningAgent) Stop() { r.ag.Stop() }
// startAgent performs the same setup as main.go's agent branch and
// launches the agent loop in the background.
func startAgent(cfgPath string) (*runningAgent, error) {
// Agent mode uses the panel's directory layout for core binaries
// and configs; on a probe-only Windows node nothing consumes it,
// but keep the wiring identical to the Linux entry so behaviour
// cannot drift between the two.
if os.Getenv("XUI_BIN_FOLDER") == "" {
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
}
cfg, err := agent.LoadConfig(cfgPath)
if err != nil {
return nil, fmt.Errorf("load agent config from %s: %w", cfgPath, err)
}
// The acknowledged traffic baseline persists next to the agent
// config (its state home), unless the config names a path.
if cfg.BaselinePath == "" {
cfg.BaselinePath = filepath.Join(filepath.Dir(cfgPath), "agent-traffic-baseline.json")
}
logger.InitLogger(logging.INFO)
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
ag := agent.New(cfg)
r := &runningAgent{ag: ag, done: make(chan error, 1)}
go func() { r.done <- ag.Run() }()
return r, nil
}
// agentHandler adapts the agent loop to the SCM protocol.
type agentHandler struct {
cfgPath string
}
func (h *agentHandler) Execute(_ []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
const accepted = svc.AcceptStop | svc.AcceptShutdown
status <- svc.Status{State: svc.StartPending}
running, err := startAgent(h.cfgPath)
if err != nil {
log.Printf("start agent: %v", err)
return false, 1
}
status <- svc.Status{State: svc.Running, Accepts: accepted}
for {
select {
case err := <-running.done:
// The agent loop ended on its own (e.g. fatal run error).
if err != nil {
log.Printf("agent exited: %v", err)
return false, 1
}
return false, 0
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
status <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
status <- svc.Status{State: svc.StopPending}
running.Stop()
<-running.done
return false, 0
default:
log.Printf("unexpected service command: %v", c.Cmd)
}
}
}
}
// runControl implements the install/uninstall/start/stop subcommands
// through the service control manager.
func runControl(cmd, cfgPath string) error {
m, err := mgr.ConnectRemote("")
if err != nil {
return fmt.Errorf("connect to service manager: %w", err)
}
defer m.Disconnect()
switch cmd {
case "install":
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("locate own binary: %w", err)
}
absCfg, err := filepath.Abs(cfgPath)
if err != nil {
return fmt.Errorf("resolve config path: %w", err)
}
if s, err := m.OpenService(serviceName); err == nil {
s.Close()
return fmt.Errorf("service %s already exists (uninstall first)", serviceName)
}
s, err := m.CreateService(serviceName, exePath, mgr.Config{
DisplayName: "3x-ui Agent",
Description: "3x-ui probe-only node agent (facts, metrics and latency probes; runs no proxy core).",
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
}, "-config", absCfg)
if err != nil {
return fmt.Errorf("create service: %w", err)
}
defer s.Close()
log.Printf("service %s installed (config: %s)", serviceName, absCfg)
return nil
case "uninstall":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
// Stop first so the binary is not locked when the operator
// deletes or replaces it; an already-stopped service is fine.
_, _ = s.Control(svc.Stop)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
st, err := s.Query()
if err != nil || st.State == svc.Stopped {
break
}
time.Sleep(300 * time.Millisecond)
}
if err := s.Delete(); err != nil {
return fmt.Errorf("delete service: %w", err)
}
log.Printf("service %s removed", serviceName)
return nil
case "start":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
if err := s.Start(); err != nil {
return fmt.Errorf("start service: %w", err)
}
log.Printf("service %s started", serviceName)
return nil
case "stop":
s, err := openService(m)
if err != nil {
return err
}
defer s.Close()
if _, err := s.Control(svc.Stop); err != nil {
return fmt.Errorf("stop service: %w", err)
}
log.Printf("service %s stop requested", serviceName)
return nil
}
return fmt.Errorf("unknown subcommand %q", cmd)
}
func openService(m *mgr.Mgr) (*mgr.Service, error) {
s, err := m.OpenService(serviceName)
if err != nil {
return nil, fmt.Errorf("open service %s: %w", serviceName, err)
}
return s, nil
}
@@ -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 />
</>
);
}
+6
View File
@@ -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]);
+2 -20
View File
@@ -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':
-10
View File
@@ -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)}`;
}
+38 -6
View File
@@ -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 && (
+21 -4
View File
@@ -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',
@@ -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">
+3 -9
View File
@@ -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,15 @@ 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).
trafficCalUpGb: number;
trafficCalDownGb: number;
statIface: string;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const NAMED_CYCLES = [
'once',
'monthly',
@@ -113,6 +120,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
};
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
setLoaded({ nodeId, meta: payload });
@@ -237,6 +245,38 @@ 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={meta.trafficCalUpGb}
step={0.1}
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calDown')}>
<InputNumber
value={meta.trafficCalDownGb}
step={0.1}
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
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>
);
}
@@ -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);
void load();
}
};
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>
);
}
+8 -246
View File
@@ -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>
);
}
+4 -2
View File
@@ -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>
+8
View File
@@ -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"`;
-27
View File
@@ -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}$/);
});
});
+102
View File
@@ -0,0 +1,102 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Installs the 3x-ui probe-only agent on Windows (64-bit).
.DESCRIPTION
Downloads x-ui-agent-windows-amd64.exe into C:\x-ui, writes
agent.json with "probeOnly": true (Windows nodes run no proxy
core: facts, metrics and latency probes only), registers the
x-ui-agent Windows service (auto-start) and starts it.
Re-running is safe: an existing service is stopped and removed
first, and any of -Master/-Token/-Name left empty is inherited
from the agent.json already on disk.
.EXAMPLE
.\install-agent.ps1 -Master "https://panel.example.com/rogue" -Token "<agent token>" -Name "win-node-1"
#>
[CmdletBinding()]
param(
[string]$Master,
[string]$Token,
[string]$Name,
[string]$InstallDir = "C:\x-ui",
[string]$AgentUrl = $env:XUI_URL
)
$ErrorActionPreference = "Stop"
# Release download base, same shape as install-agent.sh's
# RELEASE_BASE (the version segment is bumped by the release flow).
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0"
if (-not $AgentUrl) {
$AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe"
}
$ServiceName = "x-ui-agent"
$ExeName = "x-ui-agent-windows-amd64.exe"
$ExePath = Join-Path $InstallDir $ExeName
$ConfigPath = Join-Path $InstallDir "agent.json"
# Inherit missing parameters from an existing agent.json (reruns).
if (Test-Path $ConfigPath) {
$existing = Get-Content $ConfigPath -Raw | ConvertFrom-Json
if (-not $Master) { $Master = $existing.master_url }
if (-not $Token) { $Token = $existing.token }
if (-not $Name) { $Name = $existing.node_name }
}
if (-not $Master) { throw "missing -Master (and no existing agent.json to inherit from)" }
if (-not $Token) { throw "missing -Token (and no existing agent.json to inherit from)" }
if (-not $Name) { throw "missing -Name (and no existing agent.json to inherit from)" }
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
# Stop and remove any previous install so the exe file is not locked
# by a running service when it is replaced below. Prefer the old
# binary's own subcommands; fall back to sc.exe when it is gone.
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc) {
if (Test-Path $ExePath) {
& $ExePath stop | Out-Null
& $ExePath uninstall | Out-Null
}
else {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
& sc.exe delete $ServiceName | Out-Null
}
Start-Sleep -Seconds 1
}
# Download the agent binary (via a temp file, then swap into place).
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$tmpExe = "$ExePath.new"
Write-Host "Downloading $AgentUrl"
Invoke-WebRequest -Uri $AgentUrl -OutFile $tmpExe -UseBasicParsing
if (-not (Test-Path $tmpExe) -or (Get-Item $tmpExe).Length -eq 0) {
throw "agent download produced an empty file: $AgentUrl"
}
Move-Item -Force $tmpExe $ExePath
# Write agent.json. Windows agents are probe-only: probeOnly is
# always set (the design contract, not an option).
$config = [ordered]@{
master_url = $Master
token = $Token
node_name = $Name
probeOnly = $true
}
$json = $config | ConvertTo-Json
# Go's encoding/json rejects a UTF-8 BOM, which Set-Content -Encoding
# UTF8 would add on Windows PowerShell 5.1: write BOM-less UTF-8.
[System.IO.File]::WriteAllText($ConfigPath, $json, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Wrote $ConfigPath (probeOnly)"
# Register and start the service.
& $ExePath install -config $ConfigPath
if ($LASTEXITCODE -ne 0) { throw "service install failed (exit $LASTEXITCODE)" }
& $ExePath start
if ($LASTEXITCODE -ne 0) { throw "service start failed (exit $LASTEXITCODE)" }
Write-Host "3x-ui agent installed and running as service '$ServiceName'."
Write-Host "Update later by re-running this script; the master's one-click agent update is intentionally not consumed on Windows."
+30 -10
View File
@@ -1,6 +1,8 @@
#!/bin/bash
# 3x-ui-agent 节点一键安装脚本
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] [--probe-only]
# --probe-only(V2.1.0):纯探针安装——跳过 sing-box 核心下载与自检,
# agent.json 写入 probeOnly=true;agent 只跑探针通道、不运行任何核心。
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
#
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
@@ -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.0"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}"
@@ -107,18 +112,30 @@ mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
SINGBOX_VER="1.13.21"
SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}"
SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}"
info "下载 sing-box v${SINGBOX_VER}..."
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
# 本机已有的核心文件不删(关掉纯探针后可直接复用)。
info "纯探针模式:跳过 sing-box 核心下载"
else
info "下载 sing-box v${SINGBOX_VER}..."
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
fi
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
# 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
# agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
# 写配置/起服务阶段。
self_check() {
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点没有核心可检(V2.1.0),只验 agent 本体。
[[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}"
info "纯探针模式:跳过 sing-box 自检"
return 0
fi
local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
local sb_out
@@ -137,11 +154,14 @@ self_check() {
self_check
info "写入 agent 配置..."
PROBE_ONLY_JSON=""
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
"probeOnly": true'; fi
cat > "${CONF_DIR}/agent.json" <<EOF
{
"master_url": "$MASTER",
"token": "$TOKEN",
"node_name": "$NAME"
"node_name": "$NAME"${PROBE_ONLY_JSON}
}
EOF
chmod 600 "${CONF_DIR}/agent.json"
+178 -3
View File
@@ -55,6 +55,11 @@ type Config struct {
// Empty: main derives it next to the agent config file; the agent
// falls back to the bin folder.
BaselinePath string `json:"baseline_path,omitempty"`
// ProbeOnly presets probe-only mode locally (V2.1.0): no core is
// fetched, started or stopped for the node's inbounds — only the
// probe channels run. The master's per-node flag (delivered on the
// config poll) enables the same mode; either source turns it on.
ProbeOnly bool `json:"probeOnly,omitempty"`
}
// Agent represents the node agent
@@ -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 {
@@ -337,8 +421,16 @@ func (a *Agent) sendHeartbeat() error {
// costs nothing and heals a master that missed the register-time
// copy; metrics are sampled fresh each heartbeat. Old masters
// ignore both fields.
"probeFacts": collectFacts(),
"probeMetrics": collectMetrics(),
"probeFacts": collectFacts(),
}
// 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
+151
View File
@@ -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)
}
}
+56 -766
View File
@@ -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
// SetStatIfacePin records the master's counted-interface pin.
func SetStatIfacePin(name string) { statIfacePin.Store(name) }
func statIfacePinValue() string {
if v := statIfacePin.Load(); v != nil {
return v.(string)
}
return ""
}
// 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(),
}
// netCounters is one interface's cumulative byte totals.
type netCounters struct {
name string
rx, tx uint64
}
// --- 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
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
for _, c := range all {
if c.name == pin {
return []netCounters{c}
}
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
}
+778
View File
@@ -0,0 +1,778 @@
//go:build linux
package agent
// probe_linux.go is the Linux sampler for the probe collector: host
// Facts and per-heartbeat Metrics read directly from /proc and /sys
// with the standard library only. The accounting discipline (see
// probe.go) is the source's:
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
// - network totals sum only "counted" interfaces, selected by the
// three-layer filter below so no byte is counted twice;
// - boot epoch = kernel boot_id + fingerprint of the counted set.
import (
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
func sampleFacts() *ProbeFacts {
mem := parseMeminfo(readProcFile("meminfo"))
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
v4, v6 := probeAddresses()
return &ProbeFacts{
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
OS: osPrettyName(),
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
Arch: runtime.GOARCH,
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
CPUName: cpuName,
CPUCores: cpuCores,
MemTotal: mem["MemTotal"],
SwapTotal: mem["SwapTotal"],
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// collectMetrics takes one full sample. Individual sources fail soft.
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
mem := parseMeminfo(readProcFile("meminfo"))
memTotal, memUsed := memUsedFree(mem)
swapTotal, swapUsed := swapUsedFree(mem)
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
allIfaces := parseNetDev(readProcFile("net/dev"))
counted := countedInterfaces(allIfaces, sysNetRoot)
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(allIfaces, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
rxRate, txRate := probeColl.netRate(counted, time.Now())
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
return &ProbeMetrics{
BootID: bootID,
Uptime: parseUptime(readProcFile("uptime")),
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
Load: parseLoadavg(readProcFile("loadavg")),
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: tcp,
UDP: udp,
Procs: procCount(),
}
}
// --- file helpers -----------------------------------------------------------
const sysNetRoot = "/sys/class/net"
func readProcFile(name string) string {
b, err := os.ReadFile(filepath.Join("/proc", name))
if err != nil {
return ""
}
return string(b)
}
func readTrimFile(path, fallback string) string {
b, err := os.ReadFile(path)
if err != nil {
return fallback
}
if s := strings.TrimSpace(string(b)); s != "" {
return s
}
return fallback
}
// --- memory (free口径) -------------------------------------------------------
func parseMeminfo(text string) map[string]uint64 {
out := make(map[string]uint64)
for _, line := range strings.Split(text, "\n") {
key, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) == 0 {
continue
}
kb, err := strconv.ParseUint(f[0], 10, 64)
if err != nil {
continue
}
out[key] = kb * 1024
}
return out
}
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
// available above the total, as LXCFS can report, -> total - MemFree).
func memUsedFree(m map[string]uint64) (total, used uint64) {
total = m["MemTotal"]
if total == 0 {
return 0, 0
}
avail, ok := m["MemAvailable"]
if !ok {
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
}
if avail > total {
avail = m["MemFree"]
}
if avail > total {
avail = total
}
return total, total - avail
}
func swapUsedFree(m map[string]uint64) (total, used uint64) {
total = m["SwapTotal"]
free := m["SwapFree"]
if free > total {
free = total
}
return total, total - free
}
// --- cpu --------------------------------------------------------------------
func parseCPUJiffies(stat string) *cpuSample {
for _, line := range strings.Split(stat, "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line[4:])
if len(f) < 5 {
return nil
}
var v [8]uint64
var total uint64
for i := 0; i < len(f) && i < 8; i++ {
n, err := strconv.ParseUint(f[i], 10, 64)
if err != nil {
return nil
}
v[i] = n
total += n
}
return &cpuSample{total: total, idle: v[3] + v[4]}
}
return nil
}
func parseLoadavg(text string) [3]float32 {
var out [3]float32
f := strings.Fields(text)
for i := 0; i < 3 && i < len(f); i++ {
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
out[i] = float32(v)
}
}
return out
}
func parseUptime(text string) uint64 {
f := strings.Fields(text)
if len(f) == 0 {
return 0
}
v, err := strconv.ParseFloat(f[0], 64)
if err != nil || v < 0 {
return 0
}
return uint64(v)
}
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
func cpuInfo(text string) (string, uint32) {
name := ""
cores := uint32(0)
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "processor":
cores++
case "model name", "Model", "cpu model":
if name == "" {
name = strings.TrimSpace(val)
}
}
}
if cores == 0 {
cores = uint32(runtime.NumCPU())
if cores == 0 {
cores = 1
}
}
if name == "" {
name = armCPUName(text)
}
if name == "" {
name = "unknown"
}
return name, cores
}
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
func armCPUName(text string) string {
var vendor *armVendor
var names []string
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "CPU implementer":
vendor = nil
if id, ok := parseHexID(val); ok {
for i := range armVendors {
if armVendors[i].id == id {
vendor = &armVendors[i]
}
}
}
case "CPU part":
if vendor == nil {
continue
}
name := vendor.name
if id, ok := parseHexID(val); ok {
for _, p := range vendor.parts {
if p.id == id {
name = p.name
}
}
}
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
return strings.Join(names, "/")
}
func parseHexID(s string) (uint32, bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return uint32(v), true
}
type armPart struct {
id uint32
name string
}
type armVendor struct {
id uint32
name string
parts []armPart
}
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
var armVendors = []armVendor{
{0x41, "ARM", []armPart{
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
}},
{0x48, "HiSilicon", []armPart{
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
}},
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
}
// --- os / virt ---------------------------------------------------------------
func osPrettyName() string {
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
b, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(v, "\"'")
}
}
}
return "Linux"
}
// detectVirt ports the source's chain: containers first, then hypervisor.
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
// reads as "none", same caveat the source documents for cleared CPUID bits.
func detectVirt(mounts, cpuinfo string) string {
if _, err := os.Stat("/proc/vz"); err == nil {
if _, err := os.Stat("/proc/bc"); err != nil {
return "openvz"
}
}
if s := readTrimFile("/run/systemd/container", ""); s != "" {
return s
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return "docker"
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
return "podman"
}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
return "lxc"
}
}
if _, err := os.Stat("/proc/xen"); err == nil {
return "xen"
}
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
return "xen"
}
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
for _, m := range []struct{ word, name string }{
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
{"bhyve", "bhyve"}, {"xen", "xen"},
} {
if strings.Contains(dmi, m.word) {
return m.name
}
}
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
return "kvm"
}
if strings.Contains(cpuinfo, "hypervisor") {
return "vm"
}
return "none"
}
// --- network: counted set, epoch, rates -------------------------------------
func parseNetDev(text string) []netCounters {
var out []netCounters
lines := strings.Split(text, "\n")
for _, line := range lines {
name, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) < 16 {
continue
}
rx, err1 := strconv.ParseUint(f[0], 10, 64)
tx, err2 := strconv.ParseUint(f[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
}
return out
}
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
var skipIfacePrefixes = []string{
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
}
func skipIfaceName(name string) bool {
for _, p := range skipIfacePrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
// the kernel books their bytes again on the device beneath.
func isStackedName(name string) bool {
if strings.Contains(name, ".") {
return true
}
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
var tunnelLinkTypes = map[string]bool{
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
}
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
// are already counted on another device. Hardware (a `device` link) exempts
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
// type with WireGuard but carries real traffic.
func countedElsewhere(sysRoot, name string) bool {
dev := filepath.Join(sysRoot, name)
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
devtypeIs := func(set ...string) bool {
for _, line := range strings.Split(uevent, "\n") {
for _, want := range set {
if line == want {
return true
}
}
}
return false
}
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
return true
}
if entries, err := os.ReadDir(dev); err == nil {
for _, e := range entries {
if strings.HasPrefix(e.Name(), "lower_") {
return true
}
}
}
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
return false
}
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
return true
}
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
return true
}
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
}
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
func ifaceCounts(sysRoot, name string) bool {
if skipIfaceName(name) || isStackedName(name) {
return false
}
return !countedElsewhere(sysRoot, name)
}
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
out := make([]netCounters, 0, len(all))
for _, c := range all {
if ifaceCounts(sysRoot, c.name) {
out = append(out, c)
}
}
return out
}
// --- addresses (scope §1.5) ---------------------------------------------------
// probeAddresses picks one address per family: candidates come from
// non-skip interfaces that are up, are not link-local, prefer public over
// private, and for v6 avoid temporary/deprecated/tentative addresses.
func probeAddresses() (string, string) {
transient := transientV6(readProcFile("net/if_inet6"))
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
type cand struct {
ip net.IP
transient bool
}
var held []cand
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 {
continue
}
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
if !lendsAddress(ifc.Name, ip) {
continue
}
c := cand{ip: ip}
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
for _, t := range transient {
if t.Equal(ip6) {
c.transient = true
}
}
}
held = append(held, c)
}
}
pick := func(v6 bool) string {
var best *cand
for i := range held {
c := &held[i]
if (c.ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = c
continue
}
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
if cp != bp {
if cp {
best = c
}
continue
}
if v6 && best.transient && !c.transient {
best = c
}
}
if best == nil {
return ""
}
return best.ip.String()
}
return pick(false), pick(true)
}
// lendsAddress mirrors the source: a skipped interface lends no address,
// except a WireGuard interface lending a public v6 that is not in
// Cloudflare WARP's shared 2606:4700::/32.
func lendsAddress(name string, ip net.IP) bool {
if !skipIfaceName(name) {
return true
}
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
ip16 := ip.To16()
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
}
return false
}
// transientV6 parses /proc/net/if_inet6 for addresses carrying
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
func transientV6(text string) []net.IP {
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
var out []net.IP
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 6 {
continue
}
flags, err := strconv.ParseUint(f[4], 16, 32)
if err != nil || flags&transientFlags == 0 {
continue
}
if len(f[0]) != 32 {
continue
}
ip := make(net.IP, 16)
ok := true
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
if err != nil {
ok = false
break
}
ip[i] = byte(b)
}
if ok {
out = append(out, ip)
}
}
return out
}
// --- sockets / processes ------------------------------------------------------
func connCounts(v4, v6 string) (tcp, udp uint32) {
stat := func(text, prefix, key string) uint32 {
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, prefix) {
continue
}
f := strings.Fields(line[len(prefix):])
for i := 0; i+1 < len(f); i++ {
if f[i] == key {
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
return uint32(n)
}
return 0
}
}
}
return 0
}
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
return tcp, udp
}
func procCount() uint32 {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
var n uint32
for _, e := range entries {
name := e.Name()
digits := name != ""
for i := 0; i < len(name); i++ {
if name[i] < '0' || name[i] > '9' {
digits = false
break
}
}
if digits {
n++
}
}
return n
}
// --- disk (df口径) ---------------------------------------------------------------
var skipFSTypes = map[string]bool{
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
"fuse": true, "rpc_pipefs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
"glusterfs": true, "9p": true,
}
func skipFSType(fstype string) bool {
if skipFSTypes[fstype] {
return true
}
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
return true
}
return false
}
// realMountPoints ports parse_mounts: only the topmost mount per path,
// deduped by source device (zfs by pool), real storage only.
func realMountPoints(text string) []string {
type row struct{ dev, mount, fstype string }
var rows []row
top := map[string]int{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
rows = append(rows, row{f[0], f[1], f[2]})
top[f[1]] = len(rows) - 1
}
seen := map[string]bool{}
var out []string
for i, r := range rows {
if top[r.mount] != i || skipFSType(r.fstype) {
continue
}
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
continue
}
key := r.dev
if r.fstype == "zfs" {
if j := strings.IndexByte(key, '/'); j >= 0 {
key = key[:j]
}
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
}
return out
}
// diskUsage sums statfs over the mounts: total = f_blocks, used =
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
func diskUsage(mounts []string) (total, used uint64) {
for _, m := range mounts {
var st syscall.Statfs_t
if err := syscall.Statfs(m, &st); err != nil {
continue
}
bs := uint64(st.Frsize)
if bs == 0 {
bs = uint64(st.Bsize)
}
total += st.Blocks * bs
if st.Blocks > st.Bfree {
used += (st.Blocks - st.Bfree) * bs
}
}
return total, used
}
+209
View File
@@ -0,0 +1,209 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
)
// V2.1.0 probe-only tests: the mode gates both core branches of the
// config poll (no fetch, no start, no switch latch), retires a core
// that was running when the mode turns on, and self-heals back to
// the core when the mode turns off again.
// probeMaster is a fake master whose config response is assembled
// from a switchable probe_only flag plus the usual sing-box payload.
type probeMaster struct {
probeOnly atomic.Bool
trafficHit atomic.Int32
srv *httptest.Server
}
func newProbeMaster(t *testing.T) *probeMaster {
t.Helper()
m := &probeMaster{}
sum := sha256.Sum256([]byte(sbCfgA))
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"singbox_config": json.RawMessage(sbCfgA),
"singbox_config_hash": hex.EncodeToString(sum[:]),
"probe_tasks": []any{},
// The real master always sends the flag (only a pre-V2.1.0
// master omits it); the fixture mirrors that.
"probe_only": m.probeOnly.Load(),
}
_ = json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("/panel/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
m.trafficHit.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
m.srv = httptest.NewServer(mux)
t.Cleanup(m.srv.Close)
return m
}
func (m *probeMaster) agent(cfg *Config) *Agent {
if cfg == nil {
cfg = &Config{}
}
cfg.MasterURL = m.srv.URL
cfg.Token = "tok"
cfg.NodeName = "n"
return New(cfg)
}
// The master's flag alone (no local preset) must gate the poll: the
// served sing-box payload is ignored entirely.
func TestProbeOnlyMasterFlagGatesApply(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if !a.probeOnlyActive() {
t.Fatal("probe-only must be active from the master flag")
}
if a.sbSwitched {
t.Fatal("switchover latched under probe-only")
}
if a.singboxRunning() {
t.Fatal("sing-box started under probe-only")
}
if a.lastSingboxHash != "" {
t.Fatalf("lastSingboxHash = %q, want untouched (empty)", a.lastSingboxHash)
}
if _, err := os.Stat(singboxConfigPath()); !os.IsNotExist(err) {
t.Fatalf("applied config file exists under probe-only (stat err = %v)", err)
}
}
// The local agent.json preset gates even against a master that
// still serves a payload (and predates the flag): with no core
// binary on disk, not even a core fetch may be attempted.
func TestProbeOnlyLocalPresetSkipsCoreFetch(t *testing.T) {
binDir := t.TempDir() // deliberately no fake sing-box binary
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
coreSrv := fakeCoreServer(t, &hits, http.StatusOK)
defer coreSrv.Close()
useCoreBase(t, coreSrv.URL)
m := newProbeMaster(t) // probe_only absent from its responses
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if hits.Load() != 0 {
t.Fatalf("core fetch attempted %d times under local probe-only preset", hits.Load())
}
if a.sbSwitched || a.singboxRunning() {
t.Fatal("core engaged under local probe-only preset")
}
}
// Turning the flag on while sing-box is running retires the core
// exactly once; turning it off again reactivates the core from the
// unchanged hash (the poll's not-running path), without the
// bookkeeping having been dirtied in between.
func TestProbeOnlyRetiresRunningSingboxAndHealsBack(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("initial fetch: %v", err)
}
if !a.singboxRunning() || !a.sbSwitched {
t.Fatal("precondition: sing-box must be running after the normal poll")
}
hashBefore := a.lastSingboxHash
m.probeOnly.Store(true)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("probe-only fetch: %v", err)
}
if a.singboxRunning() {
t.Fatal("sing-box must be retired when probe-only turns on")
}
if !a.sbSwitched {
t.Fatal("sbSwitched must stay latched (bookkeeping untouched by probe-only)")
}
if a.lastSingboxHash != hashBefore {
t.Fatalf("lastSingboxHash dirtied: %q -> %q", hashBefore, a.lastSingboxHash)
}
// A second probe-only poll is a no-op, not an error.
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("second probe-only fetch: %v", err)
}
m.probeOnly.Store(false)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("heal-back fetch: %v", err)
}
if !a.singboxRunning() {
t.Fatal("sing-box must restart once probe-only turns off (hash unchanged)")
}
}
// Same retirement for a legacy xray core that predates the
// switchover: probe-only stops it and does not start sing-box.
func TestProbeOnlyRetiresLegacyXray(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if a.xrayProc != nil {
t.Fatal("xray core must be retired under probe-only")
}
if a.singboxRunning() || a.sbSwitched {
t.Fatal("sing-box must not engage under probe-only")
}
}
// The traffic cycle is an explicit no-op under probe-only: even
// with a core process somehow alive, nothing is collected or posted.
func TestProbeOnlyTrafficCycleIsNoop(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
// Put a core under the agent directly (bypassing the poll gate)
// to prove the traffic cycle itself refuses to touch it.
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
t.Fatalf("applySingboxConfig: %v", err)
}
if !a.singboxRunning() {
t.Fatal("precondition: fake sing-box must be running")
}
if err := a.collectAndReportTraffic(); err != nil {
t.Fatalf("collectAndReportTraffic under probe-only: %v", err)
}
if n := m.trafficHit.Load(); n != 0 {
t.Fatalf("traffic posted %d times under probe-only", n)
}
}
+6
View File
@@ -1,3 +1,9 @@
//go:build linux
// This file tests the Linux sampler's /proc and /sys parsers
// (probe_linux.go); the shared machinery has its own coverage and the
// Windows sampler's pure helpers live in probe_windows_test.go.
package agent
import (
+463
View File
@@ -0,0 +1,463 @@
//go:build windows
package agent
// probe_windows.go is the Windows sampler for the probe collector,
// feeding the same Facts/Metrics contract and accounting discipline
// as the Linux sampler (see probe.go) from the Win32 surface:
//
// - CPU%: GetSystemTimes differenced through the shared cpuPercent
// (kernel time includes idle, exactly like /proc/stat jiffies);
// - memory: GlobalMemoryStatusEx, used = TotalPhys - AvailPhys
// (the free(1) "available" column's spirit); swap is the pagefile
// pair (TotalPageFile/AvailPageFile);
// - disk: fixed drives only (GetDriveType == DRIVE_FIXED), summed
// like df: used = total - free bytes (GetDiskFreeSpaceEx's
// TotalFree, NOT the caller-available figure, which discounts
// quotas the way f_bavail discounts the ext reserve);
// - network: GetIfTable2 rows filtered to OperStatus Up, ifType
// Ethernet (6) or IEEE80211 (71), non-loopback. This approximates
// the Linux three-layer filter: Windows exposes no stacking
// topology in the MIB, but tunnel/virtual adapters report their
// own ifTypes (tunnel 131, loopback 24, ...), so an allowlist of
// physical media lands on the same counted set in practice. The
// cost of the difference: a physical-medium bond/virtual switch
// vNIC presenting as Ethernet IS counted here even where Linux
// layer 3 would have skipped it;
// - boot identity: the registry MachineGuid stands in for the
// kernel boot_id; the counted-set FNV fingerprint is shared, so a
// NIC change re-baselines exactly like on Linux;
// - uptime: GetTickCount64 (via x/sys' DurationSinceBoot wrapper);
// - load averages, TCP/UDP connection counts have no exported
// x/sys/windows (v0.47.0) source — GetSystemTimes-style raw
// counters exist for CPU/memory, but the package exports no
// GetTcpTable2/GetUdpTable2 wrapper, so per the independent-
// degradation discipline Load stays zero and TCP/UDP stay 0
// until a wrapper exists;
// - every call fails soft to zeros, like an unreadable /proc file.
import (
"net"
"os"
"runtime"
"strings"
"time"
"unsafe"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// kernel32 procs x/sys/windows v0.47.0 does not wrap.
var (
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
)
func sampleFacts() *ProbeFacts {
memTotal, _, swapTotal, _ := winMemory()
diskTotal, _ := winDiskUsage()
v4, v6 := winAddresses()
return &ProbeFacts{
Hostname: winHostname(),
OS: winOSName(),
Kernel: winKernelBuild(),
Arch: runtime.GOARCH,
Virt: winVirt(),
CPUName: winCPUName(),
CPUCores: uint32(runtime.NumCPU()),
MemTotal: memTotal,
SwapTotal: swapTotal,
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
memTotal, memUsed, swapTotal, swapUsed := winMemory()
diskTotal, diskUsed := winDiskUsage()
rows := winIfaceRows()
all := make([]netCounters, 0, len(rows))
counted := make([]netCounters, 0, len(rows))
for _, r := range rows {
c := netCounters{name: r.name, rx: r.rx, tx: r.tx}
all = append(all, c)
if winIfaceCounts(r) {
counted = append(counted, c)
}
}
if pin := statIfacePinValue(); pin != "" {
counted = pinnedInterfaces(all, pin)
}
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
rxRate, txRate := probeColl.netRate(counted, time.Now())
return &ProbeMetrics{
BootID: bootEpoch(winMachineGUID(), names),
Uptime: uint64(windows.DurationSinceBoot().Seconds()),
CPU: probeColl.cpuPercent(winCPUSample()),
Load: [3]float32{},
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: 0, // no exported x/sys wrapper (see header)
UDP: 0, // no exported x/sys wrapper (see header)
Procs: winProcCount(),
}
}
// --- registry helpers --------------------------------------------------------
func regGetString(root registry.Key, path, name string) string {
k, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
v, _, err := k.GetStringValue(name)
if err != nil {
return ""
}
return strings.TrimSpace(v)
}
func winMachineGUID() string {
return regGetString(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, "MachineGuid")
}
func winOSName() string {
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
name := regGetString(registry.LOCAL_MACHINE, key, "ProductName")
if name == "" {
return "Windows"
}
// DisplayVersion ("23H2") disambiguates: ProductName still says
// "Windows 10" on most Windows 11 installs.
if dv := regGetString(registry.LOCAL_MACHINE, key, "DisplayVersion"); dv != "" {
name += " " + dv
}
return name
}
func winKernelBuild() string {
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
if b := regGetString(registry.LOCAL_MACHINE, key, "CurrentBuild"); b != "" {
return b
}
return "unknown"
}
func winCPUName() string {
if n := regGetString(registry.LOCAL_MACHINE,
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString"); n != "" {
return n
}
return "unknown"
}
// winVirt is best-effort: the BIOS strings Windows itself surfaces.
// No match reads "unknown" (Windows guests of a hypervisor that hides
// them are indistinguishable from bare metal this way).
func winVirt() string {
const bios = `HARDWARE\DESCRIPTION\System\BIOS`
hay := strings.ToLower(regGetString(registry.LOCAL_MACHINE, bios, "SystemManufacturer") +
" " + regGetString(registry.LOCAL_MACHINE, bios, "SystemProductName"))
for _, m := range []struct{ word, name string }{
{"vmware", "vmware"}, {"virtualbox", "virtualbox"}, {"qemu", "kvm"},
{"kvm", "kvm"}, {"xen", "xen"}, {"virtual machine", "hyper-v"},
{"amazon ec2", "kvm"}, {"google", "kvm"}, {"bochs", "bochs"},
} {
if strings.Contains(hay, m.word) {
return m.name
}
}
return "unknown"
}
func winHostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "unknown"
}
// --- cpu / memory --------------------------------------------------------------
// filetime100ns flattens a FILETIME (100ns ticks) for the shared differ.
func filetime100ns(ft windows.Filetime) uint64 {
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
}
// winCPUSample reads the cumulative system times as one cpuSample:
// total = kernel + user (kernel already includes idle, like jiffies).
func winCPUSample() *cpuSample {
var idle, kernel, user windows.Filetime
r, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)))
if r == 0 {
return nil
}
return &cpuSample{
total: filetime100ns(kernel) + filetime100ns(user),
idle: filetime100ns(idle),
}
}
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
// usedFromTotalAvail is the used-column discipline as a pure
// function: used = total - avail, saturating at 0.
func usedFromTotalAvail(total, avail uint64) uint64 {
if avail > total {
return 0
}
return total - avail
}
func winMemory() (total, used, swapTotal, swapUsed uint64) {
var ms memoryStatusEx
ms.Length = uint32(unsafe.Sizeof(ms))
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
if r == 0 {
return 0, 0, 0, 0
}
return ms.TotalPhys, usedFromTotalAvail(ms.TotalPhys, ms.AvailPhys),
ms.TotalPageFile, usedFromTotalAvail(ms.TotalPageFile, ms.AvailPageFile)
}
// --- disk (df口径) -----------------------------------------------------------
// driveSpace is one fixed drive's GetDiskFreeSpaceEx result.
type driveSpace struct{ total, free uint64 }
// sumDriveSpace aggregates fixed drives the df way: used = total -
// free (the caller-available figure is deliberately not consulted).
func sumDriveSpace(ds []driveSpace) (total, used uint64) {
for _, d := range ds {
total += d.total
used += usedFromTotalAvail(d.total, d.free)
}
return total, used
}
// splitDriveStrings decodes the GetLogicalDriveStrings multi-string
// buffer (NUL-separated roots, double-NUL terminated). Pure, so the
// layout is unit-testable off-device.
func splitDriveStrings(buf []uint16) []string {
var out []string
start := 0
for i, c := range buf {
if c != 0 {
continue
}
if i == start {
break // double NUL: end of the list
}
out = append(out, windows.UTF16ToString(buf[start:i]))
start = i + 1
}
return out
}
// fixedDriveRoots lists roots ("C:\") of DRIVE_FIXED drives.
func fixedDriveRoots() []string {
n, err := windows.GetLogicalDriveStrings(0, nil)
if err != nil || n == 0 {
return nil
}
buf := make([]uint16, n)
n, err = windows.GetLogicalDriveStrings(n, &buf[0])
if err != nil {
return nil
}
var roots []string
for _, s := range splitDriveStrings(buf[:n]) {
p, err := windows.UTF16PtrFromString(s)
if err != nil {
continue
}
if windows.GetDriveType(p) == windows.DRIVE_FIXED {
roots = append(roots, s)
}
}
return roots
}
func winDiskUsage() (total, used uint64) {
var spaces []driveSpace
for _, root := range fixedDriveRoots() {
p, err := windows.UTF16PtrFromString(root)
if err != nil {
continue
}
var freeToCaller, totalBytes, totalFree uint64
if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil {
continue
}
spaces = append(spaces, driveSpace{total: totalBytes, free: totalFree})
}
return sumDriveSpace(spaces)
}
// --- network: counted set ------------------------------------------------------
// winIfaceRow is the filter-relevant slice of one MIB_IF_ROW2.
type winIfaceRow struct {
name string
ifType uint32
operUp bool
rx, tx uint64
}
// winIfaceCounts applies the counted-set rule to one row (see the
// file header for how it approximates the Linux three-layer filter).
func winIfaceCounts(r winIfaceRow) bool {
if !r.operUp {
return false
}
if r.ifType == windows.IF_TYPE_SOFTWARE_LOOPBACK {
return false
}
return r.ifType == windows.IF_TYPE_ETHERNET_CSMACD ||
r.ifType == windows.IF_TYPE_IEEE80211
}
// winIfaceRows reads the full MIB_IF_TABLE2 once; collectMetrics
// derives both the all-interfaces list (for the operator pin) and the
// counted set from it.
func winIfaceRows() []winIfaceRow {
var table *windows.MibIfTable2
if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || table == nil {
return nil
}
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
n := int(table.NumEntries)
if n == 0 {
return nil
}
rows := unsafe.Slice(&table.Table[0], n)
out := make([]winIfaceRow, 0, n)
for i := range rows {
r := &rows[i]
out = append(out, winIfaceRow{
name: windows.UTF16ToString(r.Alias[:]),
ifType: r.Type,
operUp: r.OperStatus == windows.IfOperStatusUp,
rx: r.InOctets,
tx: r.OutOctets,
})
}
return out
}
// --- processes -----------------------------------------------------------------
func winProcCount() uint32 {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return 0
}
defer windows.CloseHandle(snap)
var pe windows.ProcessEntry32
pe.Size = uint32(unsafe.Sizeof(pe))
if err := windows.Process32First(snap, &pe); err != nil {
return 0
}
var n uint32 = 1
for {
if err := windows.Process32Next(snap, &pe); err != nil {
break
}
n++
}
return n
}
// --- addresses -------------------------------------------------------------------
// winAddresses picks one address per family from up, non-loopback
// interfaces: public preferred over private, like the Linux picker —
// only without its /proc inputs (no operstate file, no temporary-
// address flags; net.Interfaces' own flags do the loopback cut).
func winAddresses() (string, string) {
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
var cands []net.IP
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsLoopback() {
continue
}
cands = append(cands, ip)
}
}
pick := func(v6 bool) string {
var best net.IP
for _, ip := range cands {
if (ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = ip
continue
}
if isPublicIP(ip) && !isPublicIP(best) {
best = ip
}
}
if best == nil {
return ""
}
return best.String()
}
return pick(false), pick(true)
}
+99
View File
@@ -0,0 +1,99 @@
//go:build windows
package agent
import (
"testing"
"golang.org/x/sys/windows"
)
// The Windows sampler's pure helpers: the Win32 calls themselves need
// a real machine, but the accounting math layered on their results is
// pinned here.
func TestFiletime100nsAndCPUSampleDiff(t *testing.T) {
lo := windows.Filetime{LowDateTime: 0x00000005, HighDateTime: 0x00000002}
if got := filetime100ns(lo); got != 0x200000005 {
t.Fatalf("filetime100ns = %#x, want 0x200000005", got)
}
// The shared differ over Windows-style counters (kernel includes
// idle): idle advanced by the full window -> 0% busy; then a
// window where only a quarter of the time is idle -> 75%.
c := &probeCollector{}
first := &cpuSample{total: 1000, idle: 400}
if got := c.cpuPercent(first); got != 0 {
t.Fatalf("first sample = %v, want 0 (baseline only)", got)
}
idleAll := &cpuSample{total: 2000, idle: 1400}
if got := c.cpuPercent(idleAll); got != 0 {
t.Fatalf("all-idle window = %v, want 0", got)
}
busy := &cpuSample{total: 3000, idle: 1650}
if got := c.cpuPercent(busy); got != 75 {
t.Fatalf("busy window = %v, want 75", got)
}
}
func TestUsedFromTotalAvail(t *testing.T) {
if got := usedFromTotalAvail(100, 30); got != 70 {
t.Fatalf("used = %d, want 70", got)
}
if got := usedFromTotalAvail(100, 100); got != 0 {
t.Fatalf("used = %d, want 0", got)
}
if got := usedFromTotalAvail(100, 130); got != 0 {
t.Fatalf("used saturates, got %d", got)
}
}
func TestSumDriveSpaceDfSemantics(t *testing.T) {
total, used := sumDriveSpace([]driveSpace{
{total: 1000, free: 250},
{total: 2000, free: 2000},
{total: 500, free: 900}, // inconsistent source clamps to full
})
if total != 3500 || used != 1250 {
t.Fatalf("sumDriveSpace = %d/%d, want 3500/1250", total, used)
}
}
func TestSplitDriveStrings(t *testing.T) {
mk := func(ss ...string) []uint16 {
var out []uint16
for _, s := range ss {
for _, r := range s {
out = append(out, uint16(r))
}
out = append(out, 0)
}
return append(out, 0)
}
got := splitDriveStrings(mk(`C:\`, `D:\`))
if len(got) != 2 || got[0] != `C:\` || got[1] != `D:\` {
t.Fatalf("splitDriveStrings = %q, want [C:\\ D:\\]", got)
}
if got := splitDriveStrings(mk()); len(got) != 0 {
t.Fatalf("empty buffer = %q, want none", got)
}
}
func TestWinIfaceCountsRule(t *testing.T) {
cases := []struct {
name string
row winIfaceRow
want bool
}{
{"up ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: true}, true},
{"up wifi", winIfaceRow{ifType: windows.IF_TYPE_IEEE80211, operUp: true}, true},
{"down ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: false}, false},
{"loopback", winIfaceRow{ifType: windows.IF_TYPE_SOFTWARE_LOOPBACK, operUp: true}, false},
{"tunnel", winIfaceRow{ifType: 131, operUp: true}, false},
{"ppp", winIfaceRow{ifType: 23, operUp: true}, false},
}
for _, tc := range cases {
if got := winIfaceCounts(tc.row); got != tc.want {
t.Errorf("%s: winIfaceCounts = %v, want %v", tc.name, got, tc.want)
}
}
}
+23
View File
@@ -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 {
+93
View File
@@ -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)
}
}
+162
View File
@@ -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
}
+130
View File
@@ -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)
}
}
+59
View File
@@ -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")
}
}
+22
View File
@@ -52,6 +52,15 @@ var execSelf = func(path string) error {
return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ())
}
// selfUpdateSupported reports whether this platform can consume a
// master update intent. Windows agents are the standalone
// xui-agent-windows binary (see cmd/xui-agent-windows), serviced by
// re-running install-agent.ps1: the release assets an intent points
// at are the panel's Linux binaries, so an intent delivered to a
// Windows node is acknowledged and skipped, never downloaded.
// A package variable so tests can exercise the skip on any OS.
var selfUpdateSupported = runtime.GOOS != "windows"
func (a *Agent) setUpdateReport(st agentUpdateStatus) {
a.updateMu.Lock()
cp := st
@@ -82,6 +91,19 @@ func (a *Agent) maybeHandleUpdate(intent *updateIntent) {
a.updateMu.Unlock()
return
}
if !selfUpdateSupported {
// Windows (see selfUpdateSupported): log once per intent and
// mark it handled so the poll does not re-log it every cycle.
// No report is filed — the master-side intent simply ages out,
// and the node updates by re-running install-agent.ps1.
a.updateMu.Lock()
if intent.RequestedAt != a.updateHandled {
a.updateHandled = intent.RequestedAt
logger.Infof("agent update to %s offered but skipped on %s: Windows agents update by re-running install-agent.ps1", intent.Target, runtime.GOOS)
}
a.updateMu.Unlock()
return
}
a.updateMu.Lock()
if a.updateRunning || intent.RequestedAt == a.updateHandled ||
(intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) {
+33
View File
@@ -159,6 +159,39 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
}
}
func TestAgentUpdateSkippedOnUnsupportedOS(t *testing.T) {
orig := selfUpdateSupported
selfUpdateSupported = false
t.Cleanup(func() { selfUpdateSupported = orig })
var hits atomic.Int32
srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits)
defer srv.Close()
a, exe := newUpdateTestAgent(t, srv.URL)
intent := &updateIntent{RequestedAt: 46, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"}
a.maybeHandleUpdate(intent)
// A repeated delivery of the same intent is also a no-op.
a.maybeHandleUpdate(intent)
if hits.Load() != 0 {
t.Fatalf("release server hit %d times on an unsupported OS, want 0", hits.Load())
}
got, _ := os.ReadFile(exe)
if string(got) != "old-binary" {
t.Fatalf("exe = %q, want untouched old-binary", got)
}
a.updateMu.Lock()
handled, running := a.updateHandled, a.updateRunning
a.updateMu.Unlock()
if handled != 46 || running {
t.Fatalf("handled=%d running=%v, want handled=46 running=false", handled, running)
}
if rep := a.currentUpdateReport(); rep != nil {
t.Fatalf("report = %+v, want none for a skipped intent", rep)
}
}
var errExecBoom = &execBoomError{}
type execBoomError struct{}
+16 -2
View File
@@ -125,16 +125,30 @@ func GetDisplayVersion() string {
return defaultDisplayVersion
}
// bareDisplayVersion is GetDisplayVersion without a single leading
// V/v. Release builds inject the number either bare ("2.1.0") or in
// the master's V-form ("V2.1.0"); the letter-prefixed getters below
// must compose from the bare form so the letter never doubles
// ("VV2.1.0" / "AV2.1.0" — the latter is what agent rows showed when
// the V-form was injected).
func bareDisplayVersion() string {
v := GetDisplayVersion()
if len(v) > 1 && (v[0] == 'V' || v[0] == 'v') {
return v[1:]
}
return v
}
// GetMasterDisplayVersion is the master's display version ("V1.0.0"),
// shown in the panel's version slot.
func GetMasterDisplayVersion() string {
return "V" + GetDisplayVersion()
return "V" + bareDisplayVersion()
}
// GetAgentDisplayVersion is the agent's display version ("A1.0.0"),
// reported in agent telemetry and shown on agent node rows.
func GetAgentDisplayVersion() string {
return "A" + GetDisplayVersion()
return "A" + bareDisplayVersion()
}
// ParseDisplayVersion parses a display version into its three numeric
+14
View File
@@ -75,6 +75,20 @@ func TestDisplayVersionDefaultsAndOverride(t *testing.T) {
if got := GetAgentDisplayVersion(); got != "A1.0.1" {
t.Fatalf("injected agent display version = %q, want A1.0.1", got)
}
// A release build may inject the number in the master's V-form:
// neither getter may double its letter ("VV1.0.2" / "AV1.0.2").
displayVersion = "V1.0.2"
if got := GetMasterDisplayVersion(); got != "V1.0.2" {
t.Fatalf("V-injected master display version = %q, want V1.0.2", got)
}
if got := GetAgentDisplayVersion(); got != "A1.0.2" {
t.Fatalf("V-injected agent display version = %q, want A1.0.2", got)
}
displayVersion = "v1.0.3"
if got := GetAgentDisplayVersion(); got != "A1.0.3" {
t.Fatalf("v-injected agent display version = %q, want A1.0.3", got)
}
}
func TestParseDisplayVersion(t *testing.T) {
+20 -8
View File
@@ -43,14 +43,19 @@ type AgentNode struct {
// its 60s heartbeat and the panel overlays them onto the node list row.
// All fields are best-effort: an agent that never reported telemetry has
// TelemetryAt == 0 and the UI keeps showing "-" for its row.
AgentVersion string `json:"agentVersion"`
XrayVersion string `json:"xrayVersion"`
UptimeSecs uint64 `json:"uptimeSecs"`
LatencyMs int `json:"latencyMs"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
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"`
MemPct float64 `json:"memPct"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
// TelemetryAt is the unix-milli time of the last heartbeat that carried
// a telemetry object.
TelemetryAt int64 `json:"telemetryAt"`
@@ -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 @@
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
View File
@@ -957,7 +957,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
case "tls":
applyShareTLSParams(stream, params)
case "reality":
applyShareRealityParams(stream, params, 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,33 +1760,15 @@ 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)
}
params["spx"] = deriveSpiderX(seed, clientKey)
// 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.
}
}
// 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 {
jsonStr, _ := json.MarshalIndent(obj, "", " ")
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
+71 -64
View File
@@ -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()
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) {
// 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()
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)
}
}
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)
}
})
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)
}
if _, ok := u.Query()["spx"]; ok {
t.Fatalf("link for %s carries spx: %s", email, link)
}
}
}
+99 -22
View File
@@ -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
@@ -215,14 +219,15 @@ func (a *AgentController) register(c *gin.Context) {
// "not reported this round" and must not overwrite the stored value. Older
// agents send no telemetry object at all and are handled exactly as before.
type heartbeatTelemetry struct {
AgentVersion *string `json:"agentVersion"`
XrayVersion *string `json:"xrayVersion"`
UptimeSeconds *uint64 `json:"uptimeSeconds"`
LatencyMs *int `json:"latencyMs"`
CpuPercent *float64 `json:"cpuPercent"`
MemPercent *float64 `json:"memPercent"`
IPv4 *string `json:"ipv4"`
IPv6 *string `json:"ipv6"`
AgentVersion *string `json:"agentVersion"`
XrayVersion *string `json:"xrayVersion"`
SingboxVersion *string `json:"singboxVersion"`
UptimeSeconds *uint64 `json:"uptimeSeconds"`
LatencyMs *int `json:"latencyMs"`
CpuPercent *float64 `json:"cpuPercent"`
MemPercent *float64 `json:"memPercent"`
IPv4 *string `json:"ipv4"`
IPv6 *string `json:"ipv6"`
}
// updateAgentTelemetry persists the reported telemetry fields onto the
@@ -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
}
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
// 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
}
raw, mErr := json.Marshal(sbCfg)
if mErr != nil {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return
}
sbRaw = raw
sbSum := sha256.Sum256(sbRaw)
sbHash = hex.EncodeToString(sbSum[:])
}
sbRaw, mErr := json.Marshal(sbCfg)
if mErr != nil {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return
}
sbSum := sha256.Sum256(sbRaw)
payload := gin.H{
"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")
}
}
+11 -11
View File
@@ -16,15 +16,15 @@ import (
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
type APIController struct {
BaseController
inboundController *InboundController
serverController *ServerController
nodeController *NodeController
agentController *AgentController
hostController *HostController
settingController *SettingController
userService panel.UserService
apiTokenService panel.ApiTokenService
Tgbot tgbot.Tgbot
inboundController *InboundController
serverController *ServerController
nodeController *NodeController
agentController *AgentController
hostController *HostController
settingController *SettingController
userService panel.UserService
apiTokenService panel.ApiTokenService
Tgbot tgbot.Tgbot
}
// NewAPIController creates a new APIController instance and initializes its routes.
@@ -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
+18 -1
View File
@@ -8,6 +8,9 @@ import (
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -167,11 +170,25 @@ func (a *NodeController) add(c *gin.Context) {
if isAgent {
view.NodeType = "agent"
nodeID := view.Id
token, _, err := issueAgentToken(&nodeID, n.Name)
token, agentID, err := issueAgentToken(&nodeID, n.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
// Probe-only (V2.1.0): preset the flag on the freshly issued
// agent row when the create form asked for it, so the very
// first config poll already serves no core payload. A failed
// preset only logs — the node and token are usable, and the
// flag can still be flipped from the edit form.
if n.ProbeOnly != nil && *n.ProbeOnly {
if err := database.GetDB().Model(&model.AgentNode{}).
Where("id = ?", agentID).
Update("probe_only", true).Error; err != nil {
logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err)
} else {
view.ProbeOnly = true
}
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
"node": view,
"agentToken": token,
@@ -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())
}
}
+50 -31
View File
@@ -51,46 +51,57 @@ 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).
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
StatIface string `json:"statIface"`
}
func metaToForm(m probe.Meta) probeMetaForm {
return probeMetaForm{
Price: m.Price,
Currency: m.Currency,
BillingCycle: m.BillingCycle,
ExpiresAt: m.ExpiresAt,
TrafficLimit: m.TrafficLimit,
TrafficMode: m.TrafficMode,
TrafficResetDay: m.TrafficResetDay,
Public: m.Public,
Sort: m.Sort,
Group: m.GroupName,
PublicRemark: m.PublicRemark,
Notify: m.Notify,
CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin,
Price: m.Price,
Currency: m.Currency,
BillingCycle: m.BillingCycle,
ExpiresAt: m.ExpiresAt,
TrafficLimit: m.TrafficLimit,
TrafficMode: m.TrafficMode,
TrafficResetDay: m.TrafficResetDay,
Public: m.Public,
Sort: m.Sort,
Group: m.GroupName,
PublicRemark: m.PublicRemark,
Notify: m.Notify,
CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin,
TrafficCalUpGB: m.TrafficCalUpGB,
TrafficCalDownGB: m.TrafficCalDownGB,
StatIface: m.StatIface,
}
}
func (f probeMetaForm) toMeta(agentID int64) probe.Meta {
return probe.Meta{
AgentID: agentID,
Price: f.Price,
Currency: f.Currency,
BillingCycle: f.BillingCycle,
ExpiresAt: f.ExpiresAt,
TrafficLimit: f.TrafficLimit,
TrafficMode: f.TrafficMode,
TrafficResetDay: f.TrafficResetDay,
Public: f.Public,
Sort: f.Sort,
GroupName: f.Group,
PublicRemark: f.PublicRemark,
Notify: f.Notify,
CountryPin: f.CountryPin,
IPv4Pin: f.IPv4Pin,
IPv6Pin: f.IPv6Pin,
AgentID: agentID,
Price: f.Price,
Currency: f.Currency,
BillingCycle: f.BillingCycle,
ExpiresAt: f.ExpiresAt,
TrafficLimit: f.TrafficLimit,
TrafficMode: f.TrafficMode,
TrafficResetDay: f.TrafficResetDay,
Public: f.Public,
Sort: f.Sort,
GroupName: f.Group,
PublicRemark: f.PublicRemark,
Notify: f.Notify,
CountryPin: f.CountryPin,
IPv4Pin: f.IPv4Pin,
IPv6Pin: f.IPv6Pin,
TrafficCalUpGB: f.TrafficCalUpGB,
TrafficCalDownGB: f.TrafficCalDownGB,
StatIface: f.StatIface,
}
}
@@ -202,6 +213,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"
+7 -11
View File
@@ -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{}
@@ -31,14 +30,12 @@ func NewProbeNotifyController(g *gin.RouterGroup) *ProbeNotifyController {
}
type probeNotifyPayload struct {
Settings probenotify.SettingsView `json:"settings"`
Coverage []service.ProbeCoverageRow `json:"coverage"`
Settings probenotify.SettingsView `json:"settings"`
}
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)
}
+22 -9
View File
@@ -211,10 +211,12 @@ func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// liveWS is the hub's browser stream: a snapshot frame every 2 s,
// text by default, binary gzip frames for the anonymous audience when
// opened with ?gzip. Audience eligibility is re-checked every tick —
// a socket outlives both the session and the public-page switch.
// liveWS is the hub's browser stream: a snapshot frame on the
// operator's fast-report cadence (V2.0.2; the hub's fixed 2 s tick
// made a 1 s agent cadence invisible up front), text by default,
// binary gzip frames for the anonymous audience when opened with
// ?gzip. Audience eligibility is re-checked every tick — a socket
// outlives both the session and the public-page switch.
func (pc *PublicProbeController) liveWS(c *gin.Context) {
authed := probeAuthed(c) && !fromOtherOrigin(c)
if !authed && !service.ProbePublicPageEnabled() {
@@ -280,18 +282,19 @@ func (pc *PublicProbeController) liveWS(c *gin.Context) {
return conn.WriteMessage(frameType, frame) == nil
}
// The first frame goes out immediately (hub's interval ticks at
// once), then one per 2 s tick.
// The first frame goes out immediately; later frames follow the
// fast-report cadence, re-read after every frame so a settings
// change lands on a live socket without a reconnect.
if !send() {
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
timer := time.NewTimer(time.Duration(service.ProbeFastIntervalSec()) * time.Second)
select {
case <-done:
timer.Stop()
return
case <-ticker.C:
case <-timer.C:
if !send() {
return
}
@@ -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, "/"))
}
+109
View File
@@ -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)
}
}
+12
View File
@@ -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
}
+11
View File
@@ -680,6 +680,17 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
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
@@ -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,7 +50,8 @@ func TestAnnotateAgentTelemetry(t *testing.T) {
TokenHash: "hash-reporting",
Name: "agent-node", NodeID: &agentNode.Id,
AgentVersion: "3.7.0-agent.1", XrayVersion: "26.6.27",
UptimeSecs: 90061, LatencyMs: 12, CpuPct: 3.5, MemPct: 41.2,
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,
}
silent := &model.AgentNode{TokenHash: "hash-silent", Name: "silent-agent-node", NodeID: &silentNode.Id}
@@ -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" {
+75 -36
View File
@@ -38,35 +38,45 @@ type NodeView struct {
// The nodes page renders them as plain text on agent rows instead
// of the panel-URL link used for traditional nodes. Empty for
// 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"`
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"`
AgentUpdateError string `json:"agentUpdateError,omitempty" example:""`
AgentUpdateTarget string `json:"agentUpdateTarget,omitempty" example:"A1.0.1"`
Guid string `json:"guid" example:"node-guid"`
Status string `json:"status" example:"online"`
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct" example:"12.5"`
MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
NetUp uint64 `json:"netUp" example:"2097152"`
NetDown uint64 `json:"netDown" example:"1048576"`
LastError string `json:"lastError" example:""`
XrayState string `json:"xrayState" example:"running"`
XrayError string `json:"xrayError" example:""`
ConfigDirty bool `json:"configDirty" example:"false"`
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
InboundCount int `json:"inboundCount" example:"3"`
ClientCount int `json:"clientCount" example:"25"`
OnlineCount int `json:"onlineCount" example:"5"`
ActiveCount int `json:"activeCount" example:"20"`
DisabledCount int `json:"disabledCount" example:"2"`
DepletedCount int `json:"depletedCount" example:"1"`
Guid string `json:"guid" example:"node-guid"`
Status string `json:"status" example:"online"`
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"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
NetUp uint64 `json:"netUp" example:"2097152"`
NetDown uint64 `json:"netDown" example:"1048576"`
LastError string `json:"lastError" example:""`
XrayState string `json:"xrayState" example:"running"`
XrayError string `json:"xrayError" example:""`
ConfigDirty bool `json:"configDirty" example:"false"`
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
InboundCount int `json:"inboundCount" example:"3"`
ClientCount int `json:"clientCount" example:"25"`
OnlineCount int `json:"onlineCount" example:"5"`
ActiveCount int `json:"activeCount" example:"20"`
DisabledCount int `json:"disabledCount" example:"2"`
DepletedCount int `json:"depletedCount" example:"1"`
// Per-category client email lists behind the counts above, from the
// same attribution bucketing, so the nodes page category chips can
// pop over the actual usernames. Empty when the category is empty.
@@ -74,10 +84,10 @@ type NodeView struct {
ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"`
DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"`
DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"`
ParentGuid string `json:"parentGuid,omitempty" example:""`
Transitive bool `json:"transitive,omitempty" example:"false"`
CreatedAt int64 `json:"createdAt" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
ParentGuid string `json:"parentGuid,omitempty" example:""`
Transitive bool `json:"transitive,omitempty" example:"false"`
CreatedAt int64 `json:"createdAt" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
}
func toNodeView(n *model.Node) *NodeView {
@@ -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.SingboxVersion != "" {
v.SingboxVersion = ag.SingboxVersion
}
if ag.AgentVersion != "" {
// New agents report the A-series display version, shown
// verbatim; older agents report the internal build version
// and keep the historical "agent " prefix.
if _, ok := config.ParseDisplayVersion(ag.AgentVersion); ok && strings.HasPrefix(ag.AgentVersion, "A") {
v.PanelVersion = ag.AgentVersion
} else {
v.PanelVersion = "agent " + ag.AgentVersion
}
v.PanelVersion = agentVersionCell(ag.AgentVersion)
v.AgentUpdateState = ag.UpdateState
v.AgentUpdateError = ag.UpdateError
v.AgentUpdateTarget = ag.UpdateTarget
@@ -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")
}
}
+20 -5
View File
@@ -30,6 +30,15 @@ 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.
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 +57,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 +66,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, &notify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil {
&m.GroupName, &m.PublicRemark, &notify, &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 +118,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,10 +127,14 @@ 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
}
@@ -0,0 +1,108 @@
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]
if tr.TotalRx != 3_500_000_000 || tr.MonthRx != 3_500_000_000 || tr.DayRx != 3_500_000_000 {
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3500000000 each", tr.TotalRx, tr.MonthRx, tr.DayRx)
}
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)
}
}
+52 -1
View File
@@ -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,55 @@ 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)
}
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
}
// 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, &notNull, &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 {
+43 -4
View File
@@ -320,15 +320,53 @@ 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
// 1 GB = 1e9 B convention (the same one the traffic limit uses).
func calBytes(gb float64) int64 {
return int64(math.Round(gb * 1e9))
}
// 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 +378,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()
}
-57
View File
@@ -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
}
+25 -3
View File
@@ -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
+33 -10
View File
@@ -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,23 +719,31 @@ 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"`
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.
func GetProbePublicSettings() ProbePublicSettings {
proxy, _ := probeSetting("github_proxy")
return ProbePublicSettings{
PublicPage: ProbePublicPageEnabled(),
SiteName: ProbeSiteName(),
Site: ProbeSiteURL(),
GitHubProxy: proxy,
PublicPage: ProbePublicPageEnabled(),
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)
}
+35
View File
@@ -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,
+133
View File
@@ -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 ""
}
+206
View File
@@ -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
}
+26 -15
View File
@@ -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": "تم إعادة تشغيل اللوحة بنجاح",
+26 -15
View File
@@ -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.",
+26 -15
View File
@@ -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",
+26 -15
View File
@@ -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": "پنل با موفقیت راه‌اندازی مجدد شد",
+26 -15
View File
@@ -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",
+26 -15
View File
@@ -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": "パネルの再起動に成功しました",
+26 -15
View File
@@ -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",
+26 -15
View File
@@ -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": "Панель успешно перезапущена",
+26 -15
View File
@@ -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ı.",
+26 -15
View File
@@ -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": "Панель успішно перезапущено",
+26 -15
View File
@@ -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",
+26 -15
View File
@@ -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": "面板已成功重启",
+26 -15
View File
@@ -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": "面板已成功重新啟動",