Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6842da3c7e | ||
|
|
6f4a4379d9 | ||
|
|
11afed3bac | ||
|
|
1ee07ba73d | ||
|
|
d70fd38978 | ||
|
|
5a7f89ee21 | ||
|
|
6dd31b3619 | ||
|
|
c59882b846 | ||
|
|
e5d42224aa | ||
|
|
7244f3c9da | ||
|
|
f9183e9dbb | ||
|
|
f99c43dd2a | ||
|
|
ef89259a49 | ||
|
|
4c04cdb1bc | ||
|
|
bdfcce0e11 | ||
|
|
7ba1841df2 | ||
|
|
3a3c7c0dc9 | ||
|
|
388e26c292 | ||
|
|
1d1258a10d | ||
|
|
81299db6ff | ||
|
|
89b65f1606 | ||
|
|
d16b888146 | ||
|
|
c94136c8a1 | ||
|
|
e7f4bfed24 | ||
|
|
6b9832cdcc | ||
|
|
e78cb8ccc5 | ||
|
|
ef662c9fdb | ||
|
|
a81eff8c0d | ||
|
|
1d7eb16784 | ||
|
|
a13179ed9b | ||
|
|
93c1620c15 | ||
|
|
ba57fb3c00 | ||
|
|
151b07b73f | ||
|
|
cec5905483 | ||
|
|
1df78dd874 | ||
|
|
92dfdc542c | ||
|
|
a25c51003e | ||
|
|
b27bcfcb5b | ||
|
|
75f10b27fa | ||
|
|
7df3a338ef | ||
|
|
5ca22ec340 | ||
|
|
2d37a6de18 | ||
|
|
db6f666ff7 | ||
|
|
d4d259fa58 | ||
|
|
9caa4e0cf9 |
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Command xui-agent is the standalone Linux build of the 3x-ui node
|
||||
// agent (agent/panel split, phase 1). Until the split, nodes ran the
|
||||
// panel binary under the "agent" subcommand, shipping the whole web
|
||||
// panel and the embedded frontend to every node. This entry links only
|
||||
// the agent's own dependency closure (internal/agent plus config,
|
||||
// logger, selfupdate, xray-api types and a few util leaves), so the node
|
||||
// artifact is a fraction of the panel binary while remaining a drop-in
|
||||
// replacement for it:
|
||||
//
|
||||
// - invocation is unchanged: service units run
|
||||
// "xui-agent agent [-config /etc/x-ui/agent.json]" (the bare
|
||||
// "-config" form is also accepted for manual runs);
|
||||
// - agent.json, the traffic baseline and the other state files keep
|
||||
// their names, locations and formats;
|
||||
// - the wire protocol with the master (register / heartbeat /
|
||||
// config / traffic / probe-report / update intent) is byte-for-
|
||||
// byte the internal/agent implementation the dual-mode binary ran.
|
||||
//
|
||||
// The agent lifecycle wiring below mirrors main.go's agent branch
|
||||
// exactly — the same internal/agent package does the work either way,
|
||||
// and cmd/xui-agent-windows is the Windows sibling of this entry.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/agent"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/memtuning"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
// Version self-report: the agent's own display version (A-form),
|
||||
// from the same build-time displayVersion injection the panel
|
||||
// uses. Kept ahead of the subcommand so it works however the
|
||||
// binary was invoked.
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "-v", "--version", "version":
|
||||
fmt.Println(config.GetAgentDisplayVersion())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Service units (systemd / OpenRC / procd / nohup fallback in
|
||||
// install-agent.sh) invoke the binary with the historical "agent"
|
||||
// subcommand; consume it so those units keep working unchanged.
|
||||
if len(args) > 0 && args[0] == "agent" {
|
||||
args = args[1:]
|
||||
} else if len(args) > 0 && args[0][0] != '-' {
|
||||
fmt.Fprintf(os.Stderr, "xui-agent: unknown subcommand %q (this binary only runs the node agent)\n", args[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
runAgent(args)
|
||||
}
|
||||
|
||||
// runAgent is main.go's agent branch, verbatim in behaviour: same
|
||||
// flags, same defaults, same shutdown wiring.
|
||||
func runAgent(args []string) {
|
||||
agentCmd := flag.NewFlagSet("agent", flag.ExitOnError)
|
||||
var configPath string
|
||||
agentCmd.StringVar(&configPath, "config", "/etc/x-ui/agent.json", "Path to agent config file")
|
||||
agentCmd.Parse(args)
|
||||
|
||||
// Agent mode always uses the panel's directory layout for the
|
||||
// xray binary, geo files and config.json. Scoped to agent mode
|
||||
// only so the panel's own relative "bin" default is untouched.
|
||||
if os.Getenv("XUI_BIN_FOLDER") == "" {
|
||||
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
|
||||
}
|
||||
|
||||
cfg, err := agent.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load agent config from %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
// The acknowledged traffic baseline persists next to the agent
|
||||
// config (its state home, e.g. /etc/x-ui), unless the config
|
||||
// names a path.
|
||||
if cfg.BaselinePath == "" {
|
||||
cfg.BaselinePath = filepath.Join(filepath.Dir(configPath), "agent-traffic-baseline.json")
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
logger.InitLogger(logging.INFO)
|
||||
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
|
||||
|
||||
// Agent memory profile (ITEM-P3 A1/M5): the same runtime tuning
|
||||
// family as the panel, with the agent's tighter budget, plus the
|
||||
// periodic release the panel runs through its job scheduler (the
|
||||
// agent has no scheduler), plus the panel's XUI_PPROF gate.
|
||||
for _, line := range memtuning.ApplyAgentMemoryTuning() {
|
||||
log.Println(line)
|
||||
}
|
||||
memtuning.StartPeriodicMemoryRelease()
|
||||
if os.Getenv("XUI_PPROF") == "true" {
|
||||
go func() {
|
||||
log.Println("pprof profiling server listening on 127.0.0.1:6060")
|
||||
if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
|
||||
log.Printf("pprof server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
ag := agent.New(cfg)
|
||||
|
||||
// Handle shutdown signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println("Shutting down agent...")
|
||||
ag.Stop()
|
||||
}()
|
||||
|
||||
if err := ag.Run(); err != nil {
|
||||
log.Fatalf("Agent error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Release process (L-UI fork)
|
||||
|
||||
Releases are published on the self-hosted Gitea (`rogue/3x-ui-agent`).
|
||||
Build with `scripts/release-build.sh`; this document is the rules that
|
||||
script encodes. Read it before cutting a release — several rules exist
|
||||
because violating them already caused a production incident once.
|
||||
|
||||
## Two independent version lines
|
||||
|
||||
The panel and the agent are maintained as **separate lines** (policy
|
||||
set 2026-10-24). They never borrow each other's numbers.
|
||||
|
||||
| line | form | source of truth | injection |
|
||||
| --- | --- | --- | --- |
|
||||
| panel (master) | `V2.4.0` | release tag | `-X .../internal/config.displayVersion` |
|
||||
| agent | `A2.3.0` | the release's `agent-version.txt` marker | `-X .../internal/config.agentDisplayVersion` (agents only) |
|
||||
|
||||
- The agent line moves **only when agent code changes** (anything
|
||||
under `cmd/xui-agent*`, `internal/agent/`, or code reachable from
|
||||
the agent binaries). A panel-only change ships a new panel release
|
||||
whose agent assets are the previous ones, untouched.
|
||||
- An agent built without injection reports the source default
|
||||
(`defaultAgentDisplayVersion` in `internal/config`), which must
|
||||
always equal the current agent line. When the agent line bumps,
|
||||
bump that constant in the same commit.
|
||||
- Masters resolve "latest agent version" from the newest release's
|
||||
`agent-version.txt` marker (`selfupdate.ResolveLatestAgentTarget`),
|
||||
never by rewriting the panel tag. Releases older than the marker
|
||||
convention (≤ V2.4.0) transparently fall back to the legacy
|
||||
tag-derived value, which is exact for them (their agent assets were
|
||||
still stamped from the tag).
|
||||
|
||||
## Asset set
|
||||
|
||||
Every release ships exactly:
|
||||
|
||||
- `x-ui-linux-amd64`, `x-ui-linux-arm64` (+ `.sha256` sidecars) — panel
|
||||
- `x-ui-agent-linux-amd64`, `x-ui-agent-linux-arm64` (+ `.sha256`) — agent
|
||||
- `agent-version.txt` — one line, the agent line's version (`A2.3.0`)
|
||||
- `geoip-country.mmdb` lives in its own standing release, not here.
|
||||
|
||||
**No Windows executable.** See the freeze below.
|
||||
|
||||
## The agent reuse rule
|
||||
|
||||
When agent code is unchanged since the previous release:
|
||||
|
||||
1. Download the previous release's agent binaries **and** their
|
||||
`.sha256` sidecars.
|
||||
2. Verify each binary against its sidecar.
|
||||
3. Upload them to the new release **byte-for-byte** — do not rebuild,
|
||||
do not re-stamp. Rebuilding the same source with a new injection
|
||||
would mint a binary that claims a version its code does not earn
|
||||
and would churn every node's update state for nothing.
|
||||
4. `agent-version.txt` still names the same agent version.
|
||||
|
||||
When agent code changed: build fresh agents, bump **only** the agent
|
||||
line (and `defaultAgentDisplayVersion`), generate fresh sidecars.
|
||||
|
||||
`scripts/release-build.sh` implements both paths (`AGENT_CHANGED=0/1`).
|
||||
|
||||
## Windows freeze
|
||||
|
||||
The Windows agent is frozen at **A2.2.1**; **V2.4.0 was the last
|
||||
release containing `x-ui-agent-windows-amd64.exe`**. One machine
|
||||
(TYYUN, probe-only) runs it and it will not be updated.
|
||||
|
||||
Consequences that are part of the release contract:
|
||||
|
||||
- `install-agent.ps1` stays pinned to the V2.4.0 release base. Do not
|
||||
bump it: newer releases contain no exe for it to download.
|
||||
- The frozen agent cannot adapt to master changes, so the master side
|
||||
of its protocol is contract-locked by
|
||||
`TestAgentLegacyWindows221Contract` (register / heartbeat /
|
||||
probe-report request shapes; config payload keys; response
|
||||
envelopes). If that test goes red, the change breaks a node that
|
||||
can never be upgraded — stop and reconsider.
|
||||
|
||||
## Pre-publish checks
|
||||
|
||||
- [ ] `agent-version.txt` content equals the agent assets' reported
|
||||
version (for reused assets: the version they already report).
|
||||
- [ ] Sizes and sha256 of every asset read back through the API after
|
||||
upload match the local files.
|
||||
- [ ] Binaries are static (`readelf -d` shows no NEEDED) and carry no
|
||||
CGO stub strings.
|
||||
- [ ] Panel self-reports the new V version; agent self-reports the
|
||||
intended A version (unchanged line ⇒ unchanged report).
|
||||
- [ ] Pilot: update ONE node first; after it converges, `stat` the
|
||||
on-disk agent binary and compare size + sha256 with the release
|
||||
asset. (V2.3.0 lesson: the version display can look right while
|
||||
the fleet still runs the previous asset generation — the update
|
||||
asset an agent fetches is decided by the agent's own build.)
|
||||
- [ ] `install-agent.sh`'s `RELEASE_BASE` points at the new tag.
|
||||
@@ -15,13 +15,6 @@ export interface NodeUpdateResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RemoteInboundOption {
|
||||
tag: string;
|
||||
remark?: string;
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export function useNodeMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
@@ -91,13 +84,5 @@ export function useNodeMutations() {
|
||||
probe: (id: number) => probeMut.mutateAsync(id),
|
||||
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> =>
|
||||
updatePanelsMut.mutateAsync({ ids, dev }),
|
||||
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||
},
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
|
||||
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
|
||||
fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
|
||||
HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"expireDiff": 0,
|
||||
"pageSize": 0,
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
@@ -55,7 +54,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"hasWarpSecret": false,
|
||||
"pageSize": 0,
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
@@ -488,23 +486,14 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"NodeMutationRequest": {
|
||||
"address": "",
|
||||
"allowPrivateAddress": false,
|
||||
"apiToken": null,
|
||||
"basePath": "",
|
||||
"clearApiToken": false,
|
||||
"enable": false,
|
||||
"id": 0,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
""
|
||||
],
|
||||
"name": "",
|
||||
"nodeType": "traditional",
|
||||
"outboundTag": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 0,
|
||||
"probeOnly": null,
|
||||
"remark": "",
|
||||
"scheme": "http",
|
||||
"tlsVerifyMode": "verify"
|
||||
"scheme": "http"
|
||||
},
|
||||
"NodeView": {
|
||||
"activeCount": 20,
|
||||
@@ -532,13 +521,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
],
|
||||
"enable": true,
|
||||
"guid": "node-guid",
|
||||
"hasApiToken": true,
|
||||
"id": 1,
|
||||
"inboundCount": 3,
|
||||
"inboundSyncMode": "all",
|
||||
"inboundTags": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"ipv4": "203.0.113.7",
|
||||
"ipv6": "2001:db8::7",
|
||||
"lastError": "",
|
||||
@@ -548,21 +532,18 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"nodeType": "traditional",
|
||||
"nodeType": "agent",
|
||||
"onlineCount": 5,
|
||||
"onlineEmails": [
|
||||
"a@example.com"
|
||||
],
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 2053,
|
||||
"probeOnly": false,
|
||||
"remark": "Primary edge",
|
||||
"scheme": "https",
|
||||
"singboxVersion": "1.13.21",
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
|
||||
@@ -17,9 +17,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sessionMaxAge": {
|
||||
"maximum": 525600,
|
||||
"minimum": 1,
|
||||
@@ -160,7 +157,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"expireDiff",
|
||||
"pageSize",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
@@ -232,9 +228,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkTemplate": {
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sessionMaxAge": {
|
||||
"maximum": 525600,
|
||||
"minimum": 1,
|
||||
@@ -379,7 +372,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"hasWarpSecret",
|
||||
"pageSize",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
@@ -2004,7 +1996,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "object"
|
||||
},
|
||||
"NodeMutationRequest": {
|
||||
"description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
|
||||
"description": "NodeMutationRequest is the node write contract. Every node is\nagent-managed: it dials out to the master and authenticates with the\nagent token issued on creation, so the request carries no panel\nendpoint credentials.",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
@@ -2012,57 +2004,28 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"allowPrivateAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiToken": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"basePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"clearApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"enum": [
|
||||
"all",
|
||||
"selected"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType distinguishes traditional panel-to-panel nodes from lightweight\nagent nodes. Agent nodes skip the panel reachability probe and the API\ntoken requirement; an agent token is issued on creation instead.",
|
||||
"enum": [
|
||||
"traditional",
|
||||
"agent"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"probeOnly": {
|
||||
"description": "ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A\npointer so updates can tell \"leave the flag alone\" (nil) from an\nexplicit on/off; applied to the bound agent rows. On create it\npresets the flag on the freshly issued agent.",
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"remark": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2072,15 +2035,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"enum": [
|
||||
"verify",
|
||||
"skip",
|
||||
"pin",
|
||||
"mtls"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2089,20 +2043,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"basePath",
|
||||
"enable",
|
||||
"id",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"name",
|
||||
"outboundTag",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"tlsVerifyMode"
|
||||
"scheme"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NodeView": {
|
||||
"description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
|
||||
"description": "NodeView is the browser/API read contract for nodes.",
|
||||
"properties": {
|
||||
"activeCount": {
|
||||
"example": 20,
|
||||
@@ -2197,10 +2146,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "node-guid",
|
||||
"type": "string"
|
||||
},
|
||||
"hasApiToken": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
@@ -2209,19 +2154,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 3,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundSyncMode": {
|
||||
"example": "all",
|
||||
"type": "string"
|
||||
},
|
||||
"inboundTags": {
|
||||
"example": [
|
||||
"in-443-tcp"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"ipv4": {
|
||||
"description": "Ipv4/Ipv6 carry the resolved externally usable host addresses for\nagent nodes (P13 telemetry, public-address resolution on top).\nThe nodes page renders them as plain text on agent rows instead\nof the panel-URL link used for traditional nodes. Empty for\ntraditional nodes and for agents with no known address yet.",
|
||||
"example": "203.0.113.7",
|
||||
@@ -2262,8 +2194,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType is \"agent\" for lightweight agent-managed nodes (an agent is\nbound to the node) and \"traditional\" otherwise. It is derived from the\nagent binding, not stored on the node row; read paths that serve the\npanel UI annotate it via annotateNodeTypes. Empty on views built\noutside those paths.",
|
||||
"example": "traditional",
|
||||
"description": "NodeType is \"agent\" for agent-managed nodes (an agent is bound to\nthe node) and \"traditional\" for a legacy row with no binding. It\nis derived from the agent binding, not stored on the node row;\nread paths that serve the panel UI annotate it via\nannotateNodeTypes. Empty on views built outside those paths.",
|
||||
"example": "agent",
|
||||
"type": "string"
|
||||
},
|
||||
"onlineCount": {
|
||||
@@ -2280,24 +2212,19 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"outboundTag": {
|
||||
"example": "direct",
|
||||
"type": "string"
|
||||
},
|
||||
"panelVersion": {
|
||||
"example": "v3.x.x",
|
||||
"type": "string"
|
||||
},
|
||||
"parentGuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedCertSha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 2053,
|
||||
"type": "integer"
|
||||
},
|
||||
"probeOnly": {
|
||||
"description": "ProbeOnly marks an agent node as probe-only (V2.1.0): no core\nruns on it, only the probe channels. Overlaid from the bound\nagent row like the update bookkeeping below; false (and absent)\nfor traditional nodes and unflagged agents.",
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"remark": {
|
||||
"example": "Primary edge",
|
||||
"type": "string"
|
||||
@@ -2306,18 +2233,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "https",
|
||||
"type": "string"
|
||||
},
|
||||
"singboxVersion": {
|
||||
"description": "SingboxVersion is the bound agent's sing-box core version\n(V2.0.1 ②), overlaid from agent telemetry like XrayVersion;\nempty for traditional nodes and agents that never reported\none. The nodes page prefers it over XrayVersion when set.",
|
||||
"example": "1.13.21",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"example": "online",
|
||||
"type": "string"
|
||||
},
|
||||
"tlsVerifyMode": {
|
||||
"example": "verify",
|
||||
"type": "string"
|
||||
},
|
||||
"transitive": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700003600,
|
||||
"format": "int64",
|
||||
@@ -2354,11 +2278,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"disabledCount",
|
||||
"enable",
|
||||
"guid",
|
||||
"hasApiToken",
|
||||
"id",
|
||||
"inboundCount",
|
||||
"inboundSyncMode",
|
||||
"inboundTags",
|
||||
"lastError",
|
||||
"lastHeartbeat",
|
||||
"latencyMs",
|
||||
@@ -2367,14 +2288,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"netDown",
|
||||
"netUp",
|
||||
"onlineCount",
|
||||
"outboundTag",
|
||||
"panelVersion",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"scheme",
|
||||
"singboxVersion",
|
||||
"status",
|
||||
"tlsVerifyMode",
|
||||
"updatedAt",
|
||||
"uptimeSecs",
|
||||
"xrayError",
|
||||
|
||||
@@ -4,8 +4,8 @@ export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type geoipFetchResult = number;
|
||||
export type rawBytes = number[];
|
||||
export type staticEgressResolver = string;
|
||||
export type trafficLocalApplyAction = number;
|
||||
export type transportBits = number;
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface AllSetting {
|
||||
expireDiff: number;
|
||||
pageSize: number;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
@@ -65,7 +64,6 @@ export interface AllSettingView {
|
||||
hasWarpSecret: boolean;
|
||||
pageSize: number;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
@@ -457,21 +455,14 @@ export interface Node {
|
||||
export interface NodeMutationRequest {
|
||||
address: string;
|
||||
allowPrivateAddress: boolean;
|
||||
apiToken?: string | null;
|
||||
basePath: string;
|
||||
clearApiToken?: boolean;
|
||||
enable: boolean;
|
||||
id: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
name: string;
|
||||
nodeType?: string;
|
||||
outboundTag: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
probeOnly?: boolean | null;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
tlsVerifyMode: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
@@ -494,11 +485,8 @@ export interface NodeView {
|
||||
disabledEmails?: string[];
|
||||
enable: boolean;
|
||||
guid: string;
|
||||
hasApiToken: boolean;
|
||||
id: number;
|
||||
inboundCount: number;
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
lastError: string;
|
||||
@@ -511,16 +499,13 @@ export interface NodeView {
|
||||
nodeType?: string;
|
||||
onlineCount: number;
|
||||
onlineEmails?: string[];
|
||||
outboundTag: string;
|
||||
panelVersion: string;
|
||||
parentGuid?: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
probeOnly?: boolean;
|
||||
remark: string;
|
||||
scheme: string;
|
||||
singboxVersion: string;
|
||||
status: string;
|
||||
tlsVerifyMode: string;
|
||||
transitive?: boolean;
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayError: string;
|
||||
|
||||
@@ -15,12 +15,12 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
export const SubLinkProviderSchema = z.unknown();
|
||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
|
||||
export const geoipFetchResultSchema = z.number().int();
|
||||
export type geoipFetchResult = z.infer<typeof geoipFetchResultSchema>;
|
||||
|
||||
export const rawBytesSchema = z.array(z.number().int());
|
||||
export type rawBytes = z.infer<typeof rawBytesSchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
export const trafficLocalApplyActionSchema = z.number().int();
|
||||
export type trafficLocalApplyAction = z.infer<typeof trafficLocalApplyActionSchema>;
|
||||
|
||||
@@ -32,7 +32,6 @@ export const AllSettingSchema = z.object({
|
||||
expireDiff: z.number().int().min(0),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
@@ -84,7 +83,6 @@ export const AllSettingViewSchema = z.object({
|
||||
hasWarpSecret: z.boolean(),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
@@ -499,21 +497,14 @@ export type Node = z.infer<typeof NodeSchema>;
|
||||
export const NodeMutationRequestSchema = z.object({
|
||||
address: z.string(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
apiToken: z.string().nullable().optional(),
|
||||
basePath: z.string(),
|
||||
clearApiToken: z.boolean().optional(),
|
||||
enable: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundSyncMode: z.enum(['all', 'selected']),
|
||||
inboundTags: z.array(z.string()),
|
||||
name: z.string(),
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
outboundTag: z.string(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
probeOnly: z.boolean().nullable().optional(),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
});
|
||||
export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
||||
|
||||
@@ -537,11 +528,8 @@ export const NodeViewSchema = z.object({
|
||||
disabledEmails: z.array(z.string()).optional(),
|
||||
enable: z.boolean(),
|
||||
guid: z.string(),
|
||||
hasApiToken: z.boolean(),
|
||||
id: z.number().int(),
|
||||
inboundCount: z.number().int(),
|
||||
inboundSyncMode: z.string(),
|
||||
inboundTags: z.array(z.string()),
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
lastError: z.string(),
|
||||
@@ -554,16 +542,13 @@ export const NodeViewSchema = z.object({
|
||||
nodeType: z.string().optional(),
|
||||
onlineCount: z.number().int(),
|
||||
onlineEmails: z.array(z.string()).optional(),
|
||||
outboundTag: z.string(),
|
||||
panelVersion: z.string(),
|
||||
parentGuid: z.string().optional(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int(),
|
||||
probeOnly: z.boolean().optional(),
|
||||
remark: z.string(),
|
||||
scheme: z.string(),
|
||||
singboxVersion: z.string(),
|
||||
status: z.string(),
|
||||
tlsVerifyMode: z.string(),
|
||||
transitive: z.boolean().optional(),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayError: z.string(),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ExportOutlined,
|
||||
GithubOutlined,
|
||||
GlobalOutlined,
|
||||
HeartOutlined,
|
||||
ImportOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
RadarChartOutlined,
|
||||
ReadOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
@@ -39,8 +37,6 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||
const REPO_URL = 'https://git.881218.xyz/rogue/3x-ui-agent';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
const RAIL_WIDTH = 72;
|
||||
@@ -78,36 +74,6 @@ const iconByName: Record<IconName, ComponentType> = {
|
||||
routing: SwapOutlined,
|
||||
};
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-donate"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<HeartOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-docs"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<ReadOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
|
||||
if (!version) return null;
|
||||
const label = formatPanelVersion(version);
|
||||
@@ -349,8 +315,6 @@ export default function AppSidebar() {
|
||||
>
|
||||
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
|
||||
</button>
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
isDark={isDark}
|
||||
@@ -402,8 +366,6 @@ export default function AppSidebar() {
|
||||
<span className="drawer-brand">L-UI</span>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle-drawer"
|
||||
isDark={isDark}
|
||||
|
||||
@@ -29,6 +29,28 @@ export function formatPanelVersion(version: string | undefined | null): string {
|
||||
return /^\d/.test(normalized) ? `v${normalized}` : v;
|
||||
}
|
||||
|
||||
// Agent-line counterpart of isPanelUpdateAvailable for the nodes page
|
||||
// badge. Since the agent/panel version decoupling, agent rows must be
|
||||
// judged against the AGENT line's latest (the release's
|
||||
// agent-version.txt marker, surfaced as displayLatestAgentVersion),
|
||||
// never against the panel's version. The current side must be a clean
|
||||
// A-form version — anything else (legacy "agent 3.7.0" reports,
|
||||
// traditional panel versions) simply gets no badge instead of a
|
||||
// guessed one, and unparseable input never reports an update (the
|
||||
// phantom-update lesson from the panel side applies here too).
|
||||
export function isAgentUpdateAvailable(latest: string, current: string): boolean {
|
||||
if (!latest || !current) return false;
|
||||
if (!/^[Aa]\d+\.\d+\.\d+$/.test(current.trim())) return false;
|
||||
const a = parseVersionParts(latest.trim().replace(/^[Aa]/, ''));
|
||||
const b = parseVersionParts(current.trim().replace(/^[Aa]/, ''));
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] > b[i]) return true;
|
||||
if (a[i] < b[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
||||
if (!latest || !current) return false;
|
||||
const a = parseVersionParts(latest);
|
||||
|
||||
@@ -34,7 +34,6 @@ export class AllSetting {
|
||||
subPort = 2096;
|
||||
subPath = '/sub/';
|
||||
subDomain = '';
|
||||
restartXrayOnClientDisable = true;
|
||||
subCertFile = '';
|
||||
subKeyFile = '';
|
||||
subUpdates = 12;
|
||||
|
||||
@@ -290,12 +290,15 @@ export function useSecurityActions({
|
||||
setSaving(true);
|
||||
try {
|
||||
/*
|
||||
* Node-assigned inbounds run on the node, so their cert files must be the
|
||||
* node's own paths (fetched through the central panel), not this panel's.
|
||||
* Only a local inbound can borrow this panel's own cert paths.
|
||||
* Node-assigned inbounds run on agent machines, which have no
|
||||
* panel cert to borrow (the old /nodes/webCert proxy went away
|
||||
* with traditional nodes, B6), so they fall through to the
|
||||
* empty-cert warning below.
|
||||
*/
|
||||
const msg =
|
||||
typeof nodeId === 'number'
|
||||
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
||||
? null
|
||||
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||
if (!msg?.success) {
|
||||
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
|
||||
import {
|
||||
CopyOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
HddOutlined,
|
||||
SwapOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, ConfigProvider, Layout, Result, Spin, message } from 'antd';
|
||||
import { DashboardOutlined, DatabaseOutlined, HddOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||
import { HttpUtil, CPUFormatter, SizeFormatter } from '@/utils';
|
||||
import {
|
||||
USAGE_CRIT_COLOR,
|
||||
USAGE_CRIT_PERCENT,
|
||||
@@ -30,7 +23,6 @@ import ConnectionsCard from './ConnectionsCard';
|
||||
import SystemStrip from './SystemStrip';
|
||||
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||
const BackupModal = lazy(() => import('./BackupModal'));
|
||||
import './IndexPage.css';
|
||||
@@ -59,8 +51,6 @@ export default function IndexPage() {
|
||||
const [showIp, setShowIp] = useState(false);
|
||||
const [backupOpen, setBackupOpen] = useState(false);
|
||||
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
|
||||
const [configTextOpen, setConfigTextOpen] = useState(false);
|
||||
const [configText, setConfigText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||
|
||||
@@ -95,27 +85,6 @@ export default function IndexPage() {
|
||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||
}
|
||||
|
||||
async function openConfig() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
|
||||
if (!msg?.success) return;
|
||||
setConfigText(JSON.stringify(msg.obj, null, 2));
|
||||
setConfigTextOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
const ok = await ClipboardManager.copyText(configText || '');
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
FileManager.downloadTextFile(configText, 'config.json');
|
||||
}
|
||||
|
||||
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
||||
const totalDisk = status.disk.total;
|
||||
const freeDisk = Math.max(0, totalDisk - status.disk.current);
|
||||
@@ -183,7 +152,6 @@ export default function IndexPage() {
|
||||
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||
panelUpdateInfo.updateAvailable)
|
||||
}
|
||||
onOpenConfig={openConfig}
|
||||
onOpenBackup={() => setBackupOpen(true)}
|
||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||
/>
|
||||
@@ -290,42 +258,6 @@ export default function IndexPage() {
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={configTextOpen}>
|
||||
<Modal
|
||||
open={configTextOpen}
|
||||
title={t('pages.index.config')}
|
||||
width={isMobile ? '100%' : 900}
|
||||
style={isMobile ? { top: 20, maxWidth: 'calc(100vw - 16px)' } : { top: 20 }}
|
||||
onCancel={() => setConfigTextOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="download"
|
||||
onClick={downloadConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
{isMobile ? 'Download' : 'config.json'}
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
onClick={copyConfig}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
icon={<CopyOutlined />}
|
||||
>
|
||||
Copy
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<JsonEditor
|
||||
value={configText}
|
||||
onChange={setConfigText}
|
||||
minHeight={isMobile ? '300px' : 'calc(100vh - 220px)'}
|
||||
maxHeight={isMobile ? '70vh' : 'calc(100vh - 220px)'}
|
||||
readOnly
|
||||
/>
|
||||
</Modal>
|
||||
</LazyMount>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tag, Tooltip } from 'antd';
|
||||
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
|
||||
import { CloudDownloadOutlined, CloudServerOutlined } from '@ant-design/icons';
|
||||
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
|
||||
@@ -10,7 +10,6 @@ interface OverviewActionBarProps {
|
||||
panelVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
onOpenConfig: () => void;
|
||||
onOpenBackup: () => void;
|
||||
onOpenPanelUpdate: () => void;
|
||||
}
|
||||
@@ -28,7 +27,6 @@ export default function OverviewActionBar({
|
||||
panelVersion,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
onOpenConfig,
|
||||
onOpenBackup,
|
||||
onOpenPanelUpdate,
|
||||
}: OverviewActionBarProps) {
|
||||
@@ -36,12 +34,6 @@ export default function OverviewActionBar({
|
||||
const size = isMobile ? ('small' as const) : ('middle' as const);
|
||||
|
||||
const actions: BarAction[] = [
|
||||
{
|
||||
key: 'config',
|
||||
icon: <ControlOutlined />,
|
||||
text: t('pages.index.config'),
|
||||
onClick: onOpenConfig,
|
||||
},
|
||||
{
|
||||
key: 'backup',
|
||||
icon: <CloudServerOutlined />,
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface PanelUpdateInfo {
|
||||
displayCurrentVersion?: string;
|
||||
displayLatestVersion?: string;
|
||||
displayUpdateAvailable?: boolean;
|
||||
// A-series agent-line latest (version decoupling): resolved by the
|
||||
// server from the newest release's agent-version.txt marker. The
|
||||
// nodes page badges agent rows against THIS, never the panel line.
|
||||
displayLatestAgentVersion?: string;
|
||||
}
|
||||
|
||||
interface BusyEvent {
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { Alert, Button, Col, Form, Input, Modal, Row, Switch, message } from 'antd';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import { HttpUtil, type Msg } from '@/utils';
|
||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||
import { NodeFormSchema, type NodeFormValues } from '@/schemas/node';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||
|
||||
import './NodeFormModal.css';
|
||||
@@ -31,9 +17,6 @@ interface NodeFormModalProps {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
node: NodeRecord | null;
|
||||
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
|
||||
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
|
||||
fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
|
||||
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
@@ -43,20 +26,13 @@ function defaultValues(): NodeFormValues {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
nodeType: 'traditional',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
hasStoredToken: false,
|
||||
probeOnly: false,
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
tlsVerifyMode: 'verify',
|
||||
pinnedCertSha256: '',
|
||||
inboundSyncMode: 'all',
|
||||
inboundTags: [],
|
||||
outboundTag: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,9 +40,6 @@ export default function NodeFormModal({
|
||||
open,
|
||||
mode,
|
||||
node,
|
||||
testConnection,
|
||||
fetchFingerprint,
|
||||
fetchInbounds,
|
||||
save,
|
||||
onOpenChange,
|
||||
}: NodeFormModalProps) {
|
||||
@@ -76,11 +49,6 @@ export default function NodeFormModal({
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [fetchingPin, setFetchingPin] = useState(false);
|
||||
const [fetchingInbounds, setFetchingInbounds] = useState(false);
|
||||
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
|
||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
@@ -95,34 +63,6 @@ export default function NodeFormModal({
|
||||
hasToken: boolean;
|
||||
} | null>(null);
|
||||
const [editMasterUrl, setEditMasterUrl] = useState('');
|
||||
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
|
||||
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
||||
const isAgent = nodeType === 'agent';
|
||||
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||
|
||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||
// when balancers exist they get a labeled group so it's clear the selection
|
||||
// routes through a balancer. Empty falls back to the placeholder ("Direct
|
||||
// connection") rather than a synthetic option, so it can't read as a second
|
||||
// "direct" next to a real freedom outbound.
|
||||
const outboundOptions = useMemo<
|
||||
(
|
||||
| { label: string; value: string }
|
||||
| { label: string; options: { label: string; value: string }[] }
|
||||
)[]
|
||||
>(() => {
|
||||
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
|
||||
if (!outboundGroups?.balancers.length) return outOpts;
|
||||
return [
|
||||
{ label: t('pages.xray.Outbounds'), options: outOpts },
|
||||
{
|
||||
label: t('pages.xray.Balancers'),
|
||||
options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })),
|
||||
},
|
||||
];
|
||||
}, [outboundGroups, t]);
|
||||
|
||||
// Reset during render, not in an effect, so the first frame is already clean.
|
||||
const [synced, setSynced] = useState<{ mode: string; node: NodeRecord | null } | null>(null);
|
||||
@@ -138,23 +78,16 @@ export default function NodeFormModal({
|
||||
...(node as unknown as Partial<NodeFormValues>),
|
||||
id: node.id,
|
||||
scheme: (node.scheme as 'http' | 'https') || base.scheme,
|
||||
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
|
||||
inboundTags: node.inboundTags ?? [],
|
||||
apiToken: '',
|
||||
hasStoredToken: node.hasApiToken ?? false,
|
||||
}
|
||||
: base;
|
||||
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
|
||||
methods.reset(next);
|
||||
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
// Load the stored install material when editing an agent node so the
|
||||
// Load the stored install material when editing a node so the
|
||||
// full install command can be re-displayed (item 19). Nodes created
|
||||
// before tokens were stored answer hasToken=false until the agent's
|
||||
// next authenticated request backfills the token.
|
||||
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
|
||||
const editNodeId = mode === 'edit' ? (node?.id ?? 0) : 0;
|
||||
useEffect(() => {
|
||||
if (!open || !editNodeId) return;
|
||||
let cancelled = false;
|
||||
@@ -178,9 +111,15 @@ 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}`;
|
||||
// The command never carries --probe-only: an agent.json preset would
|
||||
// pin probe-only on the machine (dual-source OR gate) and the panel
|
||||
// toggle alone could no longer turn it off. Probe-only is governed
|
||||
// solely by the master-side flag; the script flag stays for manual
|
||||
// installs that deliberately want the preset.
|
||||
return cmd;
|
||||
}, [shownInstall, editMasterUrl]);
|
||||
|
||||
const title = useMemo(
|
||||
@@ -188,81 +127,22 @@ export default function NodeFormModal({
|
||||
[mode, t],
|
||||
);
|
||||
|
||||
const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken);
|
||||
|
||||
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||
const token = values.apiToken.trim();
|
||||
const agentMode = values.nodeType === 'agent';
|
||||
const payload: Partial<NodeRecord> = {
|
||||
// Agent nodes have no panel address; the backend ignores these
|
||||
// (and sanitizes the stored row), so the payload keeps the
|
||||
// historical placeholder pair.
|
||||
return {
|
||||
id: values.id || 0,
|
||||
name: values.name.trim(),
|
||||
remark: values.remark?.trim() || '',
|
||||
nodeType: values.nodeType,
|
||||
scheme: values.scheme,
|
||||
// Agent nodes have no panel address; the backend ignores these.
|
||||
address: agentMode ? 'agent' : values.address.trim(),
|
||||
port: agentMode ? 2053 : values.port,
|
||||
basePath: values.basePath.trim() || '/',
|
||||
address: 'agent',
|
||||
port: 2053,
|
||||
basePath: values.basePath?.trim() || '/',
|
||||
enable: values.enable,
|
||||
allowPrivateAddress: values.allowPrivateAddress,
|
||||
tlsVerifyMode: values.tlsVerifyMode,
|
||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
||||
inboundSyncMode: values.inboundSyncMode,
|
||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||
outboundTag: values.outboundTag || '',
|
||||
probeOnly: values.probeOnly ?? false,
|
||||
};
|
||||
if (token) payload.apiToken = token;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const payload = buildPayload(methods.getValues());
|
||||
const msg = await testConnection(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
setTestResult(msg.obj);
|
||||
} else {
|
||||
setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' });
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFetchPin() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port']))) return;
|
||||
setFetchingPin(true);
|
||||
try {
|
||||
const payload = buildPayload(methods.getValues());
|
||||
const msg = await fetchFingerprint(payload);
|
||||
if (msg?.success && msg.obj) {
|
||||
methods.setValue('pinnedCertSha256', msg.obj);
|
||||
messageApi.success(t('pages.nodes.pinFetched'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
|
||||
}
|
||||
} finally {
|
||||
setFetchingPin(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFetchInbounds() {
|
||||
if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return;
|
||||
setFetchingInbounds(true);
|
||||
try {
|
||||
const msg = await fetchInbounds(buildPayload(methods.getValues()));
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
setInboundOptions(msg.obj);
|
||||
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
|
||||
}
|
||||
} finally {
|
||||
setFetchingInbounds(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFinish(values: NodeFormValues) {
|
||||
@@ -274,26 +154,13 @@ export default function NodeFormModal({
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = buildPayload(result.data);
|
||||
const agentMode = result.data.nodeType === 'agent';
|
||||
// Agent nodes have no panel to probe; skip the connection test.
|
||||
if (!agentMode) {
|
||||
const test = await testConnection(payload);
|
||||
const probe = test?.success ? test.obj : null;
|
||||
if (!probe || probe.status !== 'online') {
|
||||
setTestResult(
|
||||
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTestResult(probe);
|
||||
}
|
||||
const msg = await save(payload);
|
||||
if (msg?.success) {
|
||||
// Backend returns { node, agentToken, nodeType } for agent nodes.
|
||||
// Backend returns { node, agentToken } for a created node.
|
||||
const obj = msg.obj as unknown as
|
||||
| { agentToken?: string; node?: { id?: number } }
|
||||
| undefined;
|
||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
||||
if (mode === 'add' && obj?.agentToken) {
|
||||
setAgentToken(obj.agentToken);
|
||||
setAgentNodeName(result.data.name.trim());
|
||||
// Default the master URL to the address this panel is being
|
||||
@@ -337,27 +204,12 @@ export default function NodeFormModal({
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
<FormField label={t('pages.nodes.nodeType')} name="nodeType">
|
||||
<Select
|
||||
disabled={mode === 'edit'}
|
||||
options={[
|
||||
{ value: 'traditional', label: t('pages.nodes.nodeTypeTraditional') },
|
||||
{ value: 'agent', label: t('pages.nodes.nodeTypeAgent') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
{isAgent && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentHint')}
|
||||
/>
|
||||
)}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentHint')}
|
||||
/>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
@@ -375,70 +227,25 @@ export default function NodeFormModal({
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.scheme')}
|
||||
name="scheme"
|
||||
onAfterChange={(value) => {
|
||||
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'https', label: 'https' },
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.address')}
|
||||
name="address"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
|
||||
>
|
||||
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.port')}
|
||||
name="port"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<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>
|
||||
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.basePath')} name="basePath">
|
||||
<Input placeholder="/" />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && shownInstall && (
|
||||
{mode === 'edit' && shownInstall && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||
@@ -475,7 +282,7 @@ export default function NodeFormModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && node?.id ? (
|
||||
{mode === 'edit' && node?.id ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.nodes.probe.title')}
|
||||
@@ -486,174 +293,6 @@ export default function NodeFormModal({
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.allowPrivateAddress')}
|
||||
name="allowPrivateAddress"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.allowPrivateAddressHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.tlsVerifyMode')}
|
||||
name="tlsVerifyMode"
|
||||
tooltip={t('pages.nodes.tlsVerifyModeHint')}
|
||||
>
|
||||
<Select
|
||||
disabled={scheme === 'http'}
|
||||
options={[
|
||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'skip' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.tlsSkipWarning')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'mtls' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.mtlsFormHint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'pin' && (
|
||||
<FormField
|
||||
label={t('pages.nodes.pinnedCert')}
|
||||
name="pinnedCertSha256"
|
||||
tooltip={t('pages.nodes.pinnedCertHint')}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
|
||||
enterButton={t('pages.nodes.fetchPin')}
|
||||
loading={fetchingPin}
|
||||
onSearch={onFetchPin}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.apiToken')}
|
||||
name="apiToken"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||
tooltip={t('pages.nodes.apiTokenHint')}
|
||||
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={
|
||||
editingWithToken
|
||||
? t('pages.nodes.apiTokenKeepHint')
|
||||
: t('pages.nodes.apiTokenPlaceholder')
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.outboundTag')}
|
||||
name="outboundTag"
|
||||
tooltip={t('pages.nodes.outboundTagHint')}
|
||||
transform={{ input: (v) => (v as string) || undefined }}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('pages.nodes.outboundTagPlaceholder')}
|
||||
options={outboundOptions}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.inboundSyncMode')}
|
||||
name="inboundSyncMode"
|
||||
tooltip={t('pages.nodes.inboundSyncModeHint')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all', label: t('pages.nodes.allInbounds') },
|
||||
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{inboundSyncMode === 'selected' && (
|
||||
<FormField
|
||||
label={t('pages.nodes.inboundTags')}
|
||||
name="inboundTags"
|
||||
tooltip={t('pages.nodes.inboundTagsHint')}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
loading={fetchingInbounds}
|
||||
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
loading={fetchingInbounds}
|
||||
onClick={onFetchInbounds}
|
||||
>
|
||||
{t('pages.nodes.loadInbounds')}
|
||||
</Button>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
options={inboundOptions.map((inbound) => ({
|
||||
value: inbound.tag,
|
||||
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<div className="test-row">
|
||||
<Button type="default" loading={testing} onClick={onTest}>
|
||||
{t('pages.nodes.testConnection')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.status === 'online' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||
description={
|
||||
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionFailed')}
|
||||
description={testResult.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { BadgeProps } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ClusterOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -29,26 +28,27 @@ import {
|
||||
PlusOutlined,
|
||||
RadarChartOutlined,
|
||||
RightOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import NodeHistoryPanel from './NodeHistoryPanel';
|
||||
import { isAgentUpdateAvailable } from '@/lib/panel-version';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import './NodeList.css';
|
||||
|
||||
interface NodeListProps {
|
||||
nodes: NodeRecord[];
|
||||
// Agent line's latest version (A-form), resolved by the server from
|
||||
// the newest release's agent-version marker; rows behind it get the
|
||||
// update Tag in the panel-version column. Empty = no badges.
|
||||
agentLatestVersion?: string;
|
||||
loading?: boolean;
|
||||
isMobile?: boolean;
|
||||
latestVersion?: string;
|
||||
selectedIds: number[];
|
||||
onSelectionChange: (ids: number[]) => void;
|
||||
onAdd: () => void;
|
||||
onMtls: () => void;
|
||||
onProbeTasks: () => void;
|
||||
onEdit: (node: NodeRecord) => void;
|
||||
onDelete: (node: NodeRecord) => void;
|
||||
@@ -102,10 +102,16 @@ function CategoryChip({
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
// Agent nodes have no panel to link to: the address cell shows the
|
||||
// host IPs the agent reports with its heartbeat as plain text (P13).
|
||||
function agentIpText(record: Pick<NodeRecord, 'ipv4' | 'ipv6'>): string {
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return ips.length > 0 ? ips.join(' · ') : '-';
|
||||
}
|
||||
|
||||
function badgeStatus(status?: string): BadgeProps['status'] {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
@@ -213,13 +219,12 @@ function useRelativeTime() {
|
||||
|
||||
export default function NodeList({
|
||||
nodes,
|
||||
agentLatestVersion = '',
|
||||
loading = false,
|
||||
isMobile = false,
|
||||
latestVersion = '',
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
onAdd,
|
||||
onMtls,
|
||||
onProbeTasks,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -235,49 +240,7 @@ export default function NodeList({
|
||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Map a node GUID to its display name so a transitive sub-node can show which
|
||||
// parent it is reached through (#4983).
|
||||
const nameByGuid = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
||||
return m;
|
||||
}, [nodes]);
|
||||
|
||||
// Order direct nodes first, each immediately followed by its transitive
|
||||
// sub-nodes, so the table reads as a parent -> child tree without colliding
|
||||
// with the per-row history expander (transitive nodes carry id 0).
|
||||
const dataSource = useMemo<NodeRow[]>(() => {
|
||||
const toRow = (n: NodeRecord): NodeRow => ({
|
||||
...n,
|
||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||
key: n.transitive ? `t-${n.guid || ''}` : n.id,
|
||||
});
|
||||
const childrenByParent = new Map<string, NodeRecord[]>();
|
||||
for (const n of nodes) {
|
||||
if (n.transitive && n.parentGuid) {
|
||||
const arr = childrenByParent.get(n.parentGuid) || [];
|
||||
arr.push(n);
|
||||
childrenByParent.set(n.parentGuid, arr);
|
||||
}
|
||||
}
|
||||
const ordered: NodeRow[] = [];
|
||||
const added = new Set<string>();
|
||||
const push = (n: NodeRecord) => {
|
||||
const row = toRow(n);
|
||||
ordered.push(row);
|
||||
added.add(String(row.key));
|
||||
};
|
||||
for (const n of nodes) {
|
||||
if (n.transitive) continue;
|
||||
push(n);
|
||||
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
|
||||
}
|
||||
// Transitive nodes whose parent isn't in the list still get shown.
|
||||
for (const n of nodes) {
|
||||
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
|
||||
}
|
||||
return ordered;
|
||||
}, [nodes]);
|
||||
const dataSource = useMemo<NodeRow[]>(() => nodes.map((n) => ({ ...n, key: n.id })), [nodes]);
|
||||
|
||||
function toggleExpanded(id: number) {
|
||||
setExpandedIds((prev) => {
|
||||
@@ -294,106 +257,74 @@ export default function NodeList({
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 150,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<Tooltip
|
||||
title={t('pages.nodes.subNodeTip', {
|
||||
parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-',
|
||||
})}
|
||||
>
|
||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
||||
{t('pages.nodes.subNode')}
|
||||
</Tag>
|
||||
render: (_value, record) => (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.testConnection')}
|
||||
onClick={() => onProbe(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.probe')}>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip title={t('pages.nodes.updateAgent')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.probe')}
|
||||
onClick={() => onProbe(record)}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t('pages.nodes.updateAgent')}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip
|
||||
title={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
)}
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<EditOutlined />}
|
||||
aria-label={t('edit')}
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => onDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.enable'),
|
||||
dataIndex: 'enable',
|
||||
align: 'center',
|
||||
width: 64,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<span style={{ opacity: 0.4 }}>—</span>
|
||||
) : (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
render: (_value, record) => (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<div
|
||||
className="name-cell"
|
||||
style={record.transitive ? { paddingInlineStart: 20 } : undefined}
|
||||
>
|
||||
<span className="name">
|
||||
{record.transitive && (
|
||||
<ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />
|
||||
)}
|
||||
{record.name}
|
||||
</span>
|
||||
<div className="name-cell">
|
||||
<span className="name">{record.name}</span>
|
||||
{record.remark && <span className="remark">{record.remark}</span>}
|
||||
</div>
|
||||
),
|
||||
@@ -425,37 +356,22 @@ export default function NodeList({
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
dataIndex: 'address',
|
||||
width: 330,
|
||||
ellipsis: true,
|
||||
render: (_value, record) => {
|
||||
if (record.nodeType === 'agent') {
|
||||
// Agent nodes have no panel to link to: show the host IPs the
|
||||
// agent reports with its heartbeat as plain text on one line
|
||||
// (P13, layout per item 21).
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -493,50 +409,42 @@ export default function NodeList({
|
||||
render: (_value, record) => formatPct(record.memPct),
|
||||
},
|
||||
{
|
||||
// Core version (V2.0.1 ②): agent rows run sing-box and show
|
||||
// its version; traditional rows keep showing the xray one.
|
||||
// Core version (V2.0.1 ②): rows run sing-box and show its
|
||||
// version. Probe-only rows (V2.1.0) run no core by design: the
|
||||
// cell says so instead of a bare '-', which reads as a fault.
|
||||
title: t('pages.nodes.coreVersion'),
|
||||
dataIndex: 'xrayVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) =>
|
||||
record.nodeType === 'agent'
|
||||
? record.singboxVersion || record.xrayVersion || '-'
|
||||
: record.xrayVersion || '-',
|
||||
record.probeOnly ? (
|
||||
<Tag color="cyan" style={{ margin: 0 }}>
|
||||
{t('pages.nodes.probeOnly')}
|
||||
</Tag>
|
||||
) : (
|
||||
record.singboxVersion || record.xrayVersion || '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
// Rows carry the A-series agent display version ("A2.3.0"),
|
||||
// not an upstream panel version. The update Tag next to it is
|
||||
// judged against the AGENT line's latest (version decoupling)
|
||||
// — a panel-only release must never light it. The one-click
|
||||
// update action itself lives in the actions column.
|
||||
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||
dataIndex: 'panelVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => {
|
||||
// Agent rows carry the A-series display version ("A1.0.0"), not
|
||||
// an upstream panel version: the update badge must never light
|
||||
// up for them (the version comparator falls back to string
|
||||
// inequality for unparseable versions, which would always
|
||||
// differ) (P13, item 18).
|
||||
const canUpdate =
|
||||
record.nodeType !== 'agent' &&
|
||||
isUpdateEligible(record) &&
|
||||
isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span>{record.panelVersion || '-'}</span>
|
||||
{canUpdate && (
|
||||
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
|
||||
<Tag
|
||||
color="orange"
|
||||
style={{ margin: 0, cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
onKeyDown={activateOnKey(() => onUpdateNode(record))}
|
||||
>
|
||||
{t('pages.nodes.updateAvailable')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
render: (_value, record) => (
|
||||
<span>
|
||||
{record.panelVersion || '-'}
|
||||
{isAgentUpdateAvailable(agentLatestVersion, record.panelVersion || '') && (
|
||||
<Tooltip title={t('pages.nodes.updateAgent')}>
|
||||
<Tag color="orange" style={{ marginInlineStart: 6, marginInlineEnd: 0 }}>
|
||||
{agentLatestVersion}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.uptime'),
|
||||
@@ -603,18 +511,7 @@ export default function NodeList({
|
||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
showAddress,
|
||||
relativeTime,
|
||||
latestVersion,
|
||||
onToggleEnable,
|
||||
onProbe,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateNode,
|
||||
nameByGuid,
|
||||
],
|
||||
[t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -623,9 +520,6 @@ export default function NodeList({
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{t('pages.nodes.addNode')}
|
||||
</Button>
|
||||
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
||||
{t('pages.nodes.mtls.title')}
|
||||
</Button>
|
||||
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
|
||||
{t('pages.nodes.probeTasks.title')}
|
||||
</Button>
|
||||
@@ -645,133 +539,109 @@ export default function NodeList({
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
) : (
|
||||
dataSource.map((record) =>
|
||||
record.transitive ? (
|
||||
dataSource.map((record) => (
|
||||
<div key={record.id} className="node-card">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
||||
<div
|
||||
key={String(record.key)}
|
||||
className="node-card"
|
||||
style={{ paddingInlineStart: 16, opacity: 0.85 }}
|
||||
className="card-head"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('.card-actions'))
|
||||
toggleExpanded(record.id);
|
||||
}}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
||||
{t('pages.nodes.subNode')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={record.id} className="node-card">
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
||||
<div
|
||||
className="card-head"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('.card-actions'))
|
||||
toggleExpanded(record.id);
|
||||
}}
|
||||
>
|
||||
<RightOutlined
|
||||
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedIds.has(record.id)}
|
||||
aria-label={record.name}
|
||||
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||
/>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('info')}
|
||||
onClick={() => setStatsNode(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
<RightOutlined
|
||||
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedIds.has(record.id)}
|
||||
aria-label={record.name}
|
||||
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||
/>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('info')}
|
||||
onClick={() => setStatsNode(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.probe')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
...(isUpdateEligible(record)
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined />{' '}
|
||||
{t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="row-action-trigger"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
...(isUpdateEligible(record)
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined /> {t('pages.nodes.updateAgent')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'edit',
|
||||
label: (
|
||||
<>
|
||||
<EditOutlined /> {t('edit')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: (
|
||||
<>
|
||||
<DeleteOutlined /> {t('delete')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="row-action-trigger"
|
||||
icon={<MoreOutlined />}
|
||||
aria-label={t('more')}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -793,14 +663,9 @@ export default function NodeList({
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.address')}</span>
|
||||
<a
|
||||
href={statsNode.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{statsNode.url}
|
||||
</a>
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{agentIpText(statsNode)}
|
||||
</span>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined
|
||||
@@ -846,17 +711,16 @@ export default function NodeList({
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
|
||||
<Tag>
|
||||
{statsNode.nodeType === 'agent'
|
||||
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
|
||||
: statsNode.xrayVersion || '-'}
|
||||
</Tag>
|
||||
<Tag>{statsNode.singboxVersion || statsNode.xrayVersion || '-'}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">
|
||||
{t('pages.nodes.panelVersion') || 'Panel version'}
|
||||
</span>
|
||||
<Tag>{statsNode.panelVersion || '-'}</Tag>
|
||||
{isAgentUpdateAvailable(agentLatestVersion, statsNode.panelVersion || '') && (
|
||||
<Tag color="orange">{agentLatestVersion}</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.uptime')}</span>
|
||||
@@ -920,7 +784,7 @@ export default function NodeList({
|
||||
onChange: (keys) =>
|
||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||
disabled: !isUpdateEligible(record),
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
@@ -935,7 +799,6 @@ export default function NodeList({
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
rowExpandable: (record) => !record.transitive,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Result,
|
||||
Row,
|
||||
Spin,
|
||||
Statistic,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -34,39 +29,9 @@ import { useNodeMutations } from '@/api/queries/useNodeMutations';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import NodeList from './NodeList';
|
||||
import NodeFormModal from './NodeFormModal';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
|
||||
|
||||
// Confirm-dialog body that lets the operator pick the stable or dev channel for
|
||||
// a node panel update. Reports changes via onChange so the imperative
|
||||
// modal.confirm onOk can read the latest choice through a ref.
|
||||
function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [dev, setDev] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<p>{t('pages.nodes.updateConfirmContent')}</p>
|
||||
<Checkbox
|
||||
checked={dev}
|
||||
onChange={(e) => {
|
||||
setDev(e.target.checked);
|
||||
onChange(e.target.checked);
|
||||
}}
|
||||
>
|
||||
{t('pages.nodes.updateDevChannel')}
|
||||
</Checkbox>
|
||||
{dev && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 8 }}
|
||||
title={t('pages.index.devChannelWarning')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -80,70 +45,26 @@ export default function NodesPage() {
|
||||
}, [messageApi]);
|
||||
|
||||
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
|
||||
const {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
fetchFingerprint,
|
||||
fetchInbounds,
|
||||
probe,
|
||||
updatePanels,
|
||||
} = useNodeMutations();
|
||||
const { create, update, remove, setEnable, probe, updatePanels } = useNodeMutations();
|
||||
|
||||
const { data: latestVersion = '' } = useQuery({
|
||||
queryKey: ['server', 'panelUpdateInfo'],
|
||||
queryFn: async () => {
|
||||
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
|
||||
return msg?.obj?.latestVersion || '';
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// Agent-line latest for the node list's update badge (version
|
||||
// decoupling): read from the same update-info endpoint the index
|
||||
// page uses; the server resolves it from the newest release's
|
||||
// agent-version.txt marker. Best-effort — an empty value just
|
||||
// leaves every badge dark.
|
||||
const [agentLatestVersion, setAgentLatestVersion] = useState('');
|
||||
useEffect(() => {
|
||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||
if (msg?.success && msg.obj?.displayLatestAgentVersion) {
|
||||
setAgentLatestVersion(msg.obj.displayLatestAgentVersion);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [mtlsOpen, setMtlsOpen] = useState(false);
|
||||
const [trustCa, setTrustCa] = useState('');
|
||||
const [copyingCa, setCopyingCa] = useState(false);
|
||||
const [savingTrustCa, setSavingTrustCa] = useState(false);
|
||||
|
||||
const onCopyNodeCa = useCallback(async () => {
|
||||
setCopyingCa(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
|
||||
const ca = msg?.obj?.caCert;
|
||||
if (msg?.success && ca) {
|
||||
await navigator.clipboard.writeText(ca);
|
||||
messageApi.success(t('pages.nodes.mtls.caCopied'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('pages.nodes.mtls.caFailed'));
|
||||
} finally {
|
||||
setCopyingCa(false);
|
||||
}
|
||||
}, [messageApi, t]);
|
||||
|
||||
const onSaveTrustCa = useCallback(async () => {
|
||||
setSavingTrustCa(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.nodes.mtls.saved'));
|
||||
setMtlsOpen(false);
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
setSavingTrustCa(false);
|
||||
}
|
||||
}, [trustCa, messageApi, t]);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
setFormMode('add');
|
||||
@@ -208,8 +129,6 @@ export default function NodesPage() {
|
||||
[setEnable],
|
||||
);
|
||||
|
||||
const devRef = useRef(false);
|
||||
|
||||
const runUpdate = useCallback(
|
||||
async (ids: number[], dev: boolean) => {
|
||||
const msg = await updatePanels(ids, dev);
|
||||
@@ -232,33 +151,16 @@ export default function NodesPage() {
|
||||
[updatePanels, messageApi, t],
|
||||
);
|
||||
|
||||
// Nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
const onUpdateNode = useCallback(
|
||||
(node: NodeRecord) => {
|
||||
// Agent nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
if (node.nodeType === 'agent') {
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: (
|
||||
<UpdateChannelChoice
|
||||
onChange={(v) => {
|
||||
devRef.current = v;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], devRef.current),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
},
|
||||
[modal, t, runUpdate],
|
||||
@@ -272,19 +174,12 @@ export default function NodesPage() {
|
||||
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
|
||||
content: (
|
||||
<UpdateChannelChoice
|
||||
onChange={(v) => {
|
||||
devRef.current = v;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate(eligible, devRef.current),
|
||||
onOk: () => runUpdate(eligible, false),
|
||||
});
|
||||
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
|
||||
|
||||
@@ -362,13 +257,12 @@ export default function NodesPage() {
|
||||
<Col span={24}>
|
||||
<NodeList
|
||||
nodes={nodes}
|
||||
agentLatestVersion={agentLatestVersion}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
latestVersion={latestVersion}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onAdd={onAdd}
|
||||
onMtls={() => setMtlsOpen(true)}
|
||||
onProbeTasks={() => navigate('/settings#probe')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
@@ -388,44 +282,9 @@ export default function NodesPage() {
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
node={formNode}
|
||||
testConnection={testConnection}
|
||||
fetchFingerprint={fetchFingerprint}
|
||||
fetchInbounds={fetchInbounds}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={mtlsOpen}
|
||||
title={t('pages.nodes.mtls.title')}
|
||||
footer={null}
|
||||
onCancel={() => setMtlsOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
{t('pages.nodes.mtls.intro')}
|
||||
</Typography.Paragraph>
|
||||
<Button onClick={onCopyNodeCa} loading={copyingCa} style={{ marginBottom: 4 }}>
|
||||
{t('pages.nodes.mtls.copyCa')}
|
||||
</Button>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.nodes.mtls.copyCaHint')}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={trustCa}
|
||||
onChange={(e) => setTrustCa(e.target.value)}
|
||||
placeholder={t('pages.nodes.mtls.trustPlaceholder')}
|
||||
style={{ marginTop: 4, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||
{t('pages.nodes.mtls.trustHint')}
|
||||
</Typography.Paragraph>
|
||||
<Button type="primary" onClick={onSaveTrustCa} loading={savingTrustCa} block>
|
||||
{t('pages.nodes.mtls.save')}
|
||||
</Button>
|
||||
</Modal>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -24,12 +24,20 @@ interface ProbeMetaWire {
|
||||
ipv4Pin: string;
|
||||
ipv6Pin: string;
|
||||
// V2.0.1: ledger corrections in GB (either sign) and the counted
|
||||
// interface pin ('' = automatic selection on the agent).
|
||||
// interface pin ('' = automatic selection on the agent). Since
|
||||
// V2.2.1 the corrections are one-shot deltas: the inputs below are
|
||||
// local state (never prefilled from the wire), a save submits them
|
||||
// once, and they clear back to empty on success.
|
||||
trafficCalUpGb: number;
|
||||
trafficCalDownGb: number;
|
||||
statIface: string;
|
||||
}
|
||||
|
||||
// GB entry converts on the binary convention every byte formatter in
|
||||
// the panel and on the public page uses (V2.2.1: was decimal 1e9,
|
||||
// which made a 3000 GB limit render as 2.73 TB instead of 2.93 TB).
|
||||
const BYTES_PER_GB = 1024 ** 3;
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
const NAMED_CYCLES = [
|
||||
@@ -59,6 +67,9 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
// in NodeFormModal); visibility derives during render.
|
||||
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
|
||||
const [trafficGb, setTrafficGb] = useState(0);
|
||||
// One-shot calibration deltas (GB, either sign): null = empty box.
|
||||
const [calUpGb, setCalUpGb] = useState<number | null>(null);
|
||||
const [calDownGb, setCalDownGb] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +79,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
}).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setLoaded({ nodeId, meta: msg.obj });
|
||||
setTrafficGb(msg.obj.trafficLimit / 1e9);
|
||||
setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -116,14 +127,23 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
try {
|
||||
const payload: ProbeMetaWire = {
|
||||
...meta,
|
||||
trafficLimit: Math.round(trafficGb * 1e9),
|
||||
trafficLimit: Math.round(trafficGb * BYTES_PER_GB),
|
||||
trafficCalUpGb: calUpGb ?? 0,
|
||||
trafficCalDownGb: calDownGb ?? 0,
|
||||
};
|
||||
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
if (msg?.success) {
|
||||
setLoaded({ nodeId, meta: payload });
|
||||
// One-shot: the submitted corrections are folded in server-
|
||||
// side; the boxes clear so a later save resubmits nothing.
|
||||
setLoaded({
|
||||
nodeId,
|
||||
meta: { ...payload, trafficCalUpGb: 0, trafficCalDownGb: 0 },
|
||||
});
|
||||
setCalUpGb(null);
|
||||
setCalDownGb(null);
|
||||
messageApi.success(t('pages.nodes.probe.saved'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
|
||||
@@ -249,9 +269,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.calUp')}>
|
||||
<InputNumber
|
||||
value={meta.trafficCalUpGb}
|
||||
value={calUpGb}
|
||||
step={0.1}
|
||||
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalUpGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
@@ -259,9 +280,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.calDown')}>
|
||||
<InputNumber
|
||||
value={meta.trafficCalDownGb}
|
||||
value={calDownGb}
|
||||
step={0.1}
|
||||
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalDownGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -178,17 +178,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.restartXrayOnClientDisable')}
|
||||
description={t('pages.settings.restartXrayOnClientDisableDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.restartXrayOnClientDisable}
|
||||
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.trafficStatsInboundUplink')}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface ApiTokenRow {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
scope: 'admin' | 'monitor' | 'node-sync';
|
||||
scope: 'admin' | 'monitor';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ export const NodeRecordSchema = z
|
||||
address: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
basePath: z.string().optional(),
|
||||
apiToken: z.string().optional(),
|
||||
hasApiToken: z.boolean().optional(),
|
||||
enable: z.boolean().optional(),
|
||||
status: z.string().optional(),
|
||||
latencyMs: z.number().optional(),
|
||||
@@ -42,20 +40,11 @@ export const NodeRecordSchema = z
|
||||
xrayState: z.string().optional(),
|
||||
xrayError: z.string().optional(),
|
||||
allowPrivateAddress: z.boolean().optional(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(),
|
||||
pinnedCertSha256: z.string().optional(),
|
||||
inboundSyncMode: z.enum(['all', 'selected']).optional(),
|
||||
// Backend serializes a nil []string as null for nodes saved before #5178.
|
||||
inboundTags: z.array(z.string()).nullish(),
|
||||
outboundTag: z.string().optional(),
|
||||
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
||||
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
||||
// A node's stable GUID (online attribution is keyed on it).
|
||||
guid: z.string().optional(),
|
||||
parentGuid: z.string().optional(),
|
||||
transitive: z.boolean().optional(),
|
||||
// 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(),
|
||||
@@ -81,63 +70,24 @@ export const ProbeResultSchema = z
|
||||
})
|
||||
.loose();
|
||||
|
||||
export const NodeFormSchema = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
remark: z.string().optional(),
|
||||
// Node type: traditional panel-to-panel, or lightweight agent.
|
||||
// Agent nodes skip the panel probe and API token; an agent token is
|
||||
// issued on creation instead.
|
||||
nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
// For agent nodes the panel never dials the node (agent dials out),
|
||||
// so address/port are not needed. Required for traditional nodes.
|
||||
address: z.string().trim().optional().default(''),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
basePath: z.string(),
|
||||
// mTLS nodes authenticate via the client certificate, so the token is optional
|
||||
// there; every other verify mode still requires one (matches remote.do()).
|
||||
// Agent nodes use an agent token instead of a panel API token.
|
||||
apiToken: z.string().trim(),
|
||||
hasStoredToken: z.boolean().optional().default(false),
|
||||
enable: z.boolean(),
|
||||
allowPrivateAddress: z.boolean(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
pinnedCertSha256: z.string().optional().default(''),
|
||||
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
|
||||
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
|
||||
// serialized as null by the backend for a nil slice — tolerate both.
|
||||
inboundTags: z
|
||||
.array(z.string())
|
||||
.nullish()
|
||||
.transform((tags) => tags ?? []),
|
||||
outboundTag: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.nodeType === 'agent') return; // no panel credentials needed
|
||||
if (!val.address || val.address.trim().length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['address'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
if (val.port === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['port'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['apiToken'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
});
|
||||
export const NodeFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
remark: z.string().optional(),
|
||||
// Every node is an agent node now (B6): the panel never dials it,
|
||||
// so the connection fields are not user-editable. They stay in the
|
||||
// form values so the submit payload carries the stored row's values
|
||||
// on edit (or the historical defaults on add); the backend
|
||||
// sanitizes the endpoint for agent rows regardless.
|
||||
scheme: z.enum(['http', 'https']).optional().default('https'),
|
||||
address: z.string().optional().default(''),
|
||||
port: z.number().optional(),
|
||||
basePath: z.string().optional().default('/'),
|
||||
allowPrivateAddress: z.boolean().optional().default(false),
|
||||
// V2.1.0: probe-only toggle (see NodeRecordSchema).
|
||||
probeOnly: z.boolean().optional().default(false),
|
||||
enable: z.boolean(),
|
||||
});
|
||||
|
||||
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||
export type ProbeResult = z.infer<typeof ProbeResultSchema>;
|
||||
|
||||
@@ -41,7 +41,6 @@ export const AllSettingSchema = z
|
||||
subPort: z.number().int().min(0).max(65535).optional(),
|
||||
subPath: absolutePath.optional(),
|
||||
subDomain: z.string().optional(),
|
||||
restartXrayOnClientDisable: z.boolean().optional(),
|
||||
subCertFile: z.string().optional(),
|
||||
subKeyFile: z.string().optional(),
|
||||
subUpdates: z.number().int().min(0).max(525600).optional(),
|
||||
|
||||
@@ -16,7 +16,6 @@ function renderNodes(nodes: NodeRecord[]) {
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onProbeTasks={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
@@ -49,7 +48,6 @@ describe('NodeList clients-cell category chips', () => {
|
||||
name: 'agent-node',
|
||||
enable: true,
|
||||
status: 'online',
|
||||
nodeType: 'agent',
|
||||
clientCount: 9,
|
||||
activeCount: 2,
|
||||
activeEmails: ['active-a@x', 'active-b@x'],
|
||||
|
||||
@@ -9,16 +9,16 @@ const noop = () => {};
|
||||
|
||||
function sampleNodes(): NodeRecord[] {
|
||||
return [
|
||||
{ id: 1, name: 'parent', guid: 'p1', transitive: false, enable: true, status: 'online' },
|
||||
{ id: 0, name: 'child-a', guid: 'ca', parentGuid: 'p1', transitive: true },
|
||||
{ id: 0, name: 'child-b', guid: 'cb', parentGuid: 'p1', transitive: true },
|
||||
{ id: 1, name: 'node-a', guid: 'p1', enable: true, status: 'online' },
|
||||
{ id: 2, name: 'node-b', guid: 'ca', enable: true, status: 'online' },
|
||||
{ id: 3, name: 'node-c', guid: 'cb', enable: true, status: 'online' },
|
||||
];
|
||||
}
|
||||
|
||||
describe('NodeList desktop table row keys', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('gives transitive sub-node rows distinct keys instead of colliding on id 0', () => {
|
||||
it('keys rows by node id without duplicate-key warnings', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderWithProviders(
|
||||
@@ -28,7 +28,6 @@ describe('NodeList desktop table row keys', () => {
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onProbeTasks={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { formatPanelVersion, isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
import { formatPanelVersion, isAgentUpdateAvailable, isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
|
||||
// The agent badge judges an agent row against the AGENT line's latest
|
||||
// only (version decoupling): a newer PANEL version must never light
|
||||
// it, and the comparison is numeric per segment.
|
||||
describe('isAgentUpdateAvailable', () => {
|
||||
it('flags a strictly newer agent-line latest', () => {
|
||||
expect(isAgentUpdateAvailable('A2.4.0', 'A2.3.0')).toBe(true);
|
||||
expect(isAgentUpdateAvailable('A2.10.0', 'A2.9.9')).toBe(true);
|
||||
expect(isAgentUpdateAvailable('A3.0.0', 'A2.9.9')).toBe(true);
|
||||
});
|
||||
|
||||
it('stays dark when the agent is current or ahead of its line', () => {
|
||||
expect(isAgentUpdateAvailable('A2.3.0', 'A2.3.0')).toBe(false);
|
||||
expect(isAgentUpdateAvailable('A2.3.0', 'A2.4.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a bare latest and a lowercase a on the current side', () => {
|
||||
expect(isAgentUpdateAvailable('2.4.0', 'A2.3.0')).toBe(true);
|
||||
expect(isAgentUpdateAvailable('A2.4.0', 'a2.3.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('never badges non-agent version cells', () => {
|
||||
// Traditional panel rows and legacy pre-display agent reports
|
||||
// carry no A-form version: no badge, whatever the agent line says.
|
||||
expect(isAgentUpdateAvailable('A2.4.0', '3.7.0')).toBe(false);
|
||||
expect(isAgentUpdateAvailable('A2.4.0', '2.0.0')).toBe(false);
|
||||
expect(isAgentUpdateAvailable('A2.4.0', 'agent 3.7.0-agent.1')).toBe(false);
|
||||
expect(isAgentUpdateAvailable('A2.4.0', '')).toBe(false);
|
||||
expect(isAgentUpdateAvailable('', 'A2.3.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('never flags on unparseable input', () => {
|
||||
expect(isAgentUpdateAvailable('dev-latest', 'A2.3.0')).toBe(false);
|
||||
expect(isAgentUpdateAvailable('A2.4.0', 'A2.3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Parity with web/service/panel.go isNewerVersion.
|
||||
describe('isPanelUpdateAvailable', () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
||||
@@ -108,6 +108,8 @@ github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#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. FROZEN at V2.4.0 — do not bump: the Windows
|
||||
# agent line ended at A2.2.1 and V2.4.0 is the last release that
|
||||
# contains the Windows exe (later releases ship no exe; see
|
||||
# docs/release.md, "Windows freeze").
|
||||
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.4.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."
|
||||
+97
-15
@@ -1,6 +1,8 @@
|
||||
#!/bin/bash
|
||||
# 3x-ui-agent 节点一键安装脚本
|
||||
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
|
||||
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] [--probe-only]
|
||||
# --probe-only(V2.1.0):纯探针安装——跳过 sing-box 核心下载与自检,
|
||||
# agent.json 写入 probeOnly=true;agent 只跑探针通道、不运行任何核心。
|
||||
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
|
||||
#
|
||||
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
|
||||
@@ -9,7 +11,7 @@
|
||||
#
|
||||
# 测试用环境变量(一般无需设置):
|
||||
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | procd | none,默认 auto 探测
|
||||
# XUI_URL agent 二进制下载地址覆盖
|
||||
#
|
||||
# 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用
|
||||
@@ -28,12 +30,13 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
[[ $EUID -ne 0 ]] && error "请用 root 运行"
|
||||
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
|
||||
|
||||
MASTER=""; TOKEN=""; NAME=""
|
||||
MASTER=""; TOKEN=""; NAME=""; PROBE_ONLY=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--master) MASTER="$2"; shift 2;;
|
||||
--token) TOKEN="$2"; shift 2;;
|
||||
--name) NAME="$2"; shift 2;;
|
||||
--probe-only) PROBE_ONLY=1; shift;;
|
||||
*) error "未知参数: $1";;
|
||||
esac
|
||||
done
|
||||
@@ -53,6 +56,8 @@ if [[ -f "$AGENT_JSON_PATH" ]]; then
|
||||
if [[ -z "$MASTER" && -n "$f_master" ]]; then MASTER="$f_master"; INHERITED+=("主控地址"); fi
|
||||
if [[ -z "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); fi
|
||||
if [[ -z "$NAME" && -n "$f_name" ]]; then NAME="$f_name"; INHERITED+=("节点名"); fi
|
||||
# probeOnly 是布尔字段,json_get 只取字符串值,这里单独按 true 沿用。
|
||||
if [[ -z "$PROBE_ONLY" ]] && grep -q '"probeOnly"[[:space:]]*:[[:space:]]*true' "$AGENT_JSON_PATH"; then PROBE_ONLY=1; INHERITED+=("纯探针"); fi
|
||||
if [[ ${#INHERITED[@]} -gt 0 ]]; then
|
||||
info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)"
|
||||
elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then
|
||||
@@ -76,8 +81,13 @@ 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"
|
||||
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
|
||||
#
|
||||
# agent/主控拆分(期一):下载的是 agent 专用产物 x-ui-agent-linux-<arch>
|
||||
# (独立入口 cmd/xui-agent,不再是主控双模式二进制)。该资产自拆分版
|
||||
# 发行起才有——RELEASE_BASE 的升版由发版步完成,指向拆分版即可用;
|
||||
# 若需装拆分前的旧版节点,显式 XUI_URL 指回 x-ui-linux-<arch> 即可。
|
||||
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.5.0"
|
||||
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-agent-linux-${BIN_ARCH}}"
|
||||
|
||||
ROOT="${XUI_ROOT:-}"
|
||||
XUI_HOME="${ROOT}/usr/local/x-ui"
|
||||
@@ -107,18 +117,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 +159,14 @@ self_check() {
|
||||
self_check
|
||||
|
||||
info "写入 agent 配置..."
|
||||
PROBE_ONLY_JSON=""
|
||||
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
|
||||
"probeOnly": true'; fi
|
||||
cat > "${CONF_DIR}/agent.json" <<EOF
|
||||
{
|
||||
"master_url": "$MASTER",
|
||||
"token": "$TOKEN",
|
||||
"node_name": "$NAME"
|
||||
"node_name": "$NAME"${PROBE_ONLY_JSON}
|
||||
}
|
||||
EOF
|
||||
chmod 600 "${CONF_DIR}/agent.json"
|
||||
@@ -200,7 +225,7 @@ stop_legacy_agent() {
|
||||
|
||||
detect_init_system() {
|
||||
case "${XUI_INIT_SYSTEM:-auto}" in
|
||||
systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
systemd|openrc|procd|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
esac
|
||||
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo systemd; return
|
||||
@@ -208,6 +233,9 @@ detect_init_system() {
|
||||
if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then
|
||||
echo openrc; return
|
||||
fi
|
||||
if [ -f /etc/rc.common ] && command -v procd >/dev/null 2>&1; then
|
||||
echo procd; return
|
||||
fi
|
||||
echo none
|
||||
}
|
||||
|
||||
@@ -266,10 +294,14 @@ command_args="agent"
|
||||
# supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起,
|
||||
# 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background
|
||||
# 只负责后台化、崩溃不重启。respawn_max=0 = 不限次(同 systemd 的持续重试)。
|
||||
# respawn_period 必须为正数:supervise-daemon 启动时校验该值,0 直接报
|
||||
# "Invalid respawn-period value '0'" 拒绝拉起(V2.2.1 ⑤,Alpine 真机实证);
|
||||
# 取 1800(真机上其他 supervise-daemon 服务同值)。max=0 不限次时 period
|
||||
# 仅是计数窗口,不影响持续重试语义。
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
respawn_period=0
|
||||
respawn_period=1800
|
||||
pidfile="/run/x-ui-agent.pid"
|
||||
output_log="/var/log/x-ui-agent.log"
|
||||
error_log="/var/log/x-ui-agent.log"
|
||||
@@ -302,9 +334,58 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# 以 procd 服务方式安装并启动(开机自启,OpenWrt)
|
||||
start_via_procd() {
|
||||
local init_dir="${ROOT}/etc/init.d"
|
||||
mkdir -p "$init_dir"
|
||||
# 服务脚本与 2026-10-23 AX6600 真机手工验证版逐字一致(V2.2.1 ④
|
||||
# 收编):路径按脚本变量展开,XUI_ROOT 为空时即验证版的
|
||||
# /usr/local/x-ui/xui-agent 与 /etc/x-ui/agent.json。procd 托管
|
||||
# + respawn 守护,stdout/stderr 进 syslog(logread 查看)。
|
||||
cat > "${init_dir}/xui-agent" <<EOF
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=99
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command ${AGENT_BIN} agent -config ${CONF_DIR}/agent.json
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
EOF
|
||||
chmod +x "${init_dir}/xui-agent"
|
||||
info "已生成 procd 服务文件: ${init_dir}/xui-agent"
|
||||
if [ -n "$ROOT" ]; then
|
||||
info "(XUI_ROOT 模式:跳过 /etc/init.d 实际操作)"
|
||||
return 0
|
||||
fi
|
||||
/etc/init.d/xui-agent stop 2>/dev/null || true
|
||||
stop_legacy_agent
|
||||
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
|
||||
/etc/init.d/xui-agent enable
|
||||
/etc/init.d/xui-agent start
|
||||
sleep 2
|
||||
if pgrep -f "${AGENT_BIN} agent" >/dev/null 2>&1 || ps w 2>/dev/null | grep "[x]ui-agent agent" >/dev/null 2>&1; then
|
||||
info "agent 已由 procd 托管启动(开机自启)"
|
||||
echo ""
|
||||
echo " 主控: $MASTER"
|
||||
echo " 节点名: $NAME"
|
||||
echo " 日志: logread -f"
|
||||
echo " agent 会在 30 秒内从主控拉取配置"
|
||||
else
|
||||
error "agent 服务启动失败,查看: logread | tail -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
# 无服务管理器时回落 nohup(无开机自启,明确警告)
|
||||
start_via_nohup() {
|
||||
warn "未检测到 systemd / OpenRC,退回 nohup 方式启动"
|
||||
# BusyBox 精简系统可能连 nohup 都没有(AX6600 实证):先讲清再退,
|
||||
# 不要假装启动、走到最后才报一个看不懂的失败。
|
||||
command -v nohup >/dev/null 2>&1 || error "未检测到 systemd / OpenRC / procd,且本机无 nohup 命令,无法启动 agent"
|
||||
warn "未检测到 systemd / OpenRC / procd,退回 nohup 方式启动"
|
||||
warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!"
|
||||
stop_legacy_agent
|
||||
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
|
||||
@@ -328,5 +409,6 @@ info "启动 agent..."
|
||||
case "$(detect_init_system)" in
|
||||
systemd) start_via_systemd;;
|
||||
openrc) start_via_openrc;;
|
||||
procd) start_via_procd;;
|
||||
*) start_via_nohup;;
|
||||
esac
|
||||
|
||||
@@ -55,6 +55,11 @@ type Config struct {
|
||||
// Empty: main derives it next to the agent config file; the agent
|
||||
// falls back to the bin folder.
|
||||
BaselinePath string `json:"baseline_path,omitempty"`
|
||||
// ProbeOnly presets probe-only mode locally (V2.1.0): no core is
|
||||
// fetched, started or stopped for the node's inbounds — only the
|
||||
// probe channels run. The master's per-node flag (delivered on the
|
||||
// config poll) enables the same mode; either source turns it on.
|
||||
ProbeOnly bool `json:"probeOnly,omitempty"`
|
||||
}
|
||||
|
||||
// Agent represents the node agent
|
||||
@@ -124,6 +129,11 @@ type Agent struct {
|
||||
fastIntervalSec atomic.Int64
|
||||
fastUnsupported atomic.Bool
|
||||
|
||||
// Probe-only mode (V2.1.0): the master's per-node flag rides the
|
||||
// config poll; the local config preset is the other source. While
|
||||
// either is on, the poll skips both core branches entirely.
|
||||
masterProbeOnly atomic.Bool
|
||||
|
||||
// Latency probe runner (item 7 B4, see ping.go): task set pushed
|
||||
// on the config poll, samples returned on the heartbeat.
|
||||
probes *probeRunner
|
||||
@@ -505,6 +515,10 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
// report, automatic interface selection).
|
||||
ProbeFastInterval *int64 `json:"probe_fast_interval"`
|
||||
ProbeStatIface *string `json:"probe_stat_iface"`
|
||||
// ProbeOnly is a pointer on purpose (V2.1.0), same as the other
|
||||
// probe keys: a master too old to know the mode omits the field
|
||||
// and the agent keeps its current state.
|
||||
ProbeOnly *bool `json:"probe_only"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
@@ -523,6 +537,9 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
if result.ProbeStatIface != nil {
|
||||
SetStatIfacePin(*result.ProbeStatIface)
|
||||
}
|
||||
if result.ProbeOnly != nil {
|
||||
a.masterProbeOnly.Store(*result.ProbeOnly)
|
||||
}
|
||||
|
||||
// One-click update intents ride this poll (item 20); handle them even
|
||||
// when the config itself is unchanged.
|
||||
@@ -534,6 +551,16 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
a.probes.applyTasks(*result.ProbeTasks)
|
||||
}
|
||||
|
||||
// Probe-only mode (V2.1.0): no core config is applied at all. The
|
||||
// master already withholds the payload for flagged nodes, but the
|
||||
// local preset must gate even against a master that still serves
|
||||
// one. A core left running from before the mode was turned on is
|
||||
// retired by the convergence step.
|
||||
if a.probeOnlyActive() {
|
||||
a.retireCoresForProbeOnly()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Switchover (ITEM6 B5): a master that serves a sing-box config
|
||||
// has made sing-box the node's formal runtime. Apply the config
|
||||
// through the switch sequence (which retires the legacy xray
|
||||
@@ -598,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeOnlyActive reports whether probe-only mode is in force from
|
||||
// either source: the local agent.json preset or the master's
|
||||
// per-node flag on the config poll.
|
||||
func (a *Agent) probeOnlyActive() bool {
|
||||
return a.config.ProbeOnly || a.masterProbeOnly.Load()
|
||||
}
|
||||
|
||||
// retireCoresForProbeOnly converges a node that entered probe-only
|
||||
// mode while a core was still running: the core's tail traffic is
|
||||
// flushed best-effort (an explicit operator mode change outranks the
|
||||
// unreported tail, unlike the switchover retire path, which keeps
|
||||
// the core alive on a failed flush) and the core is stopped. Later
|
||||
// polls find nothing running and skip, so the retirement happens
|
||||
// exactly once per entry. The switchover bookkeeping (sbSwitched,
|
||||
// lastSingboxHash) is left untouched on purpose: leaving probe-only
|
||||
// re-serves the same config hash, and the existing "hash current but
|
||||
// core not running" poll path reactivates the core on its own.
|
||||
func (a *Agent) retireCoresForProbeOnly() {
|
||||
if a.xrayProc != nil {
|
||||
if err := a.collectAndReportXrayTraffic(); err != nil {
|
||||
logger.Warningf("probe-only: flush xray traffic before retiring its core failed: %v", err)
|
||||
}
|
||||
a.stopXray()
|
||||
logger.Info("probe-only mode: xray core retired")
|
||||
}
|
||||
if a.singboxRunning() {
|
||||
if err := a.collectAndReportSingboxTraffic(); err != nil {
|
||||
logger.Warningf("probe-only: flush sing-box traffic before retiring its core failed: %v", err)
|
||||
}
|
||||
a.stopSingbox()
|
||||
logger.Info("probe-only mode: sing-box core retired")
|
||||
}
|
||||
}
|
||||
|
||||
// singboxRunning reports whether the managed sing-box process is
|
||||
// currently alive.
|
||||
func (a *Agent) singboxRunning() bool {
|
||||
@@ -914,6 +975,12 @@ func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error
|
||||
// (xray and, since ITEM6 B3, sing-box). The two cores keep separate
|
||||
// clients and baselines; a failure in one never blocks the other.
|
||||
func (a *Agent) collectAndReportTraffic() error {
|
||||
// Probe-only nodes run no core (V2.1.0): the cycle is an explicit
|
||||
// no-op, so a core retired by the mode can never be mistaken for
|
||||
// a live one by a later tick.
|
||||
if a.probeOnlyActive() {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
if err := a.collectAndReportXrayTraffic(); err != nil {
|
||||
firstErr = err
|
||||
|
||||
+41
-786
@@ -1,34 +1,34 @@
|
||||
package agent
|
||||
|
||||
// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go:
|
||||
// host Facts (once per process) and per-heartbeat Metrics read directly from
|
||||
// /proc and /sys with the standard library only. The port keeps the source's
|
||||
// probe.go is the platform-neutral core of the probe collector, ported
|
||||
// from monitor-probe's agent collector (collect.rs, MIT): host Facts
|
||||
// (once per process) and per-heartbeat Metrics with the source's
|
||||
// accounting discipline:
|
||||
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
|
||||
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
|
||||
// - network totals sum only "counted" interfaces — loopback, container,
|
||||
// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer
|
||||
// filter so no byte is counted twice;
|
||||
// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set,
|
||||
// so a set change re-baselines exactly like a reboot;
|
||||
// - every source degrades independently: an unreadable file yields a zero
|
||||
// field, never a failed collection round.
|
||||
// - memory "used" is free(1)'s column: total - available;
|
||||
// - disk "used" is df(1)'s: real storage only, total - free blocks
|
||||
// (not the unprivileged-available figure);
|
||||
// - network totals sum only "counted" interfaces — loopback,
|
||||
// container, tunnel and stacked devices are excluded by a filter so
|
||||
// no byte is counted twice;
|
||||
// - boot epoch = a stable boot identity + FNV-1a fingerprint of the
|
||||
// counted set, so a set change re-baselines exactly like a reboot;
|
||||
// - every source degrades independently: an unreadable source yields
|
||||
// a zero field, never a failed collection round.
|
||||
//
|
||||
// The samplers themselves are per-OS: probe_linux.go reads /proc and
|
||||
// /sys, probe_windows.go reads the Win32 performance/registry surface.
|
||||
// Both feed the shared rate/epoch machinery below and must keep the
|
||||
// same accounting semantics; where a source has no equivalent on the
|
||||
// platform, the field stays zero (documented at the sampler).
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
)
|
||||
|
||||
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled
|
||||
@@ -72,7 +72,7 @@ type ProbeMetrics struct {
|
||||
}
|
||||
|
||||
// probeCollector holds the cross-sample state the rates need (previous CPU
|
||||
// jiffies and per-interface counters). Kept in memory only, like the source.
|
||||
// counters and per-interface counters). Kept in memory only, like the source.
|
||||
type probeCollector struct {
|
||||
mu sync.Mutex
|
||||
prevCPU *cpuSample
|
||||
@@ -80,6 +80,9 @@ type probeCollector struct {
|
||||
prevNetT time.Time
|
||||
}
|
||||
|
||||
// cpuSample is one cumulative CPU reading in whatever tick unit the
|
||||
// platform reports: total covers every state, idle only the idle state.
|
||||
// The differencing in cpuPercent is unit-agnostic.
|
||||
type cpuSample struct{ total, idle uint64 }
|
||||
|
||||
var (
|
||||
@@ -94,37 +97,15 @@ func collectFacts() *ProbeFacts {
|
||||
return factsCache
|
||||
}
|
||||
|
||||
func sampleFacts() *ProbeFacts {
|
||||
mem := parseMeminfo(readProcFile("meminfo"))
|
||||
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
|
||||
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
|
||||
v4, v6 := probeAddresses()
|
||||
return &ProbeFacts{
|
||||
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
|
||||
OS: osPrettyName(),
|
||||
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
|
||||
Arch: runtime.GOARCH,
|
||||
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
|
||||
CPUName: cpuName,
|
||||
CPUCores: cpuCores,
|
||||
MemTotal: mem["MemTotal"],
|
||||
SwapTotal: mem["SwapTotal"],
|
||||
DiskTotal: diskTotal,
|
||||
AgentVersion: config.GetAgentDisplayVersion(),
|
||||
IPv4: v4,
|
||||
IPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
// statIfacePin (V2.0.1) names the single interface to count when
|
||||
// the operator pinned one on the master (its probe_meta.stat_iface,
|
||||
// delivered on the config poll). "" keeps the automatic counted-set
|
||||
// selection below. The pin bypasses the three-layer filter on
|
||||
// purpose: the operator named this exact device, including when it
|
||||
// is one the filter would skip (a tunnel the node actually bills
|
||||
// on). A pinned name absent from /proc/net/dev counts nothing —
|
||||
// the boot epoch changes with the counted set, so the master
|
||||
// re-baselines instead of booking a phantom delta.
|
||||
// selection. The pin bypasses the filter on purpose: the operator
|
||||
// named this exact device, including when it is one the filter would
|
||||
// skip (a tunnel the node actually bills on). A pinned name absent
|
||||
// from the interface table counts nothing — the boot epoch changes
|
||||
// with the counted set, so the master re-baselines instead of booking
|
||||
// a phantom delta.
|
||||
var statIfacePin atomic.Value // string
|
||||
|
||||
// SetStatIfacePin records the master's counted-interface pin.
|
||||
@@ -137,6 +118,12 @@ func statIfacePinValue() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// netCounters is one interface's cumulative byte totals.
|
||||
type netCounters struct {
|
||||
name string
|
||||
rx, tx uint64
|
||||
}
|
||||
|
||||
func pinnedInterfaces(all []netCounters, pin string) []netCounters {
|
||||
for _, c := range all {
|
||||
if c.name == pin {
|
||||
@@ -146,151 +133,9 @@ func pinnedInterfaces(all []netCounters, pin string) []netCounters {
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectMetrics takes one full sample. Individual sources fail soft.
|
||||
func collectMetrics() *ProbeMetrics {
|
||||
probeColl.mu.Lock()
|
||||
defer probeColl.mu.Unlock()
|
||||
mem := parseMeminfo(readProcFile("meminfo"))
|
||||
memTotal, memUsed := memUsedFree(mem)
|
||||
swapTotal, swapUsed := swapUsedFree(mem)
|
||||
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
|
||||
allIfaces := parseNetDev(readProcFile("net/dev"))
|
||||
counted := countedInterfaces(allIfaces, sysNetRoot)
|
||||
if pin := statIfacePinValue(); pin != "" {
|
||||
counted = pinnedInterfaces(allIfaces, pin)
|
||||
}
|
||||
var rxTotal, txTotal uint64
|
||||
names := make([]string, 0, len(counted))
|
||||
for _, c := range counted {
|
||||
rxTotal += c.rx
|
||||
txTotal += c.tx
|
||||
names = append(names, c.name)
|
||||
}
|
||||
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
|
||||
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
||||
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
|
||||
return &ProbeMetrics{
|
||||
BootID: bootID,
|
||||
Uptime: parseUptime(readProcFile("uptime")),
|
||||
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
|
||||
Load: parseLoadavg(readProcFile("loadavg")),
|
||||
MemTotal: memTotal,
|
||||
MemUsed: memUsed,
|
||||
SwapTotal: swapTotal,
|
||||
SwapUsed: swapUsed,
|
||||
DiskTotal: diskTotal,
|
||||
DiskUsed: diskUsed,
|
||||
NetRxTotal: rxTotal,
|
||||
NetTxTotal: txTotal,
|
||||
NetRx: rxRate,
|
||||
NetTx: txRate,
|
||||
TCP: tcp,
|
||||
UDP: udp,
|
||||
Procs: procCount(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- file helpers -----------------------------------------------------------
|
||||
|
||||
const sysNetRoot = "/sys/class/net"
|
||||
|
||||
func readProcFile(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("/proc", name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func readTrimFile(path, fallback string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
if s := strings.TrimSpace(string(b)); s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// --- memory (free口径) -------------------------------------------------------
|
||||
|
||||
func parseMeminfo(text string) map[string]uint64 {
|
||||
out := make(map[string]uint64)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(rest)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
kb, err := strconv.ParseUint(f[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[key] = kb * 1024
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
|
||||
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
|
||||
// available above the total, as LXCFS can report, -> total - MemFree).
|
||||
func memUsedFree(m map[string]uint64) (total, used uint64) {
|
||||
total = m["MemTotal"]
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
avail, ok := m["MemAvailable"]
|
||||
if !ok {
|
||||
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
|
||||
}
|
||||
if avail > total {
|
||||
avail = m["MemFree"]
|
||||
}
|
||||
if avail > total {
|
||||
avail = total
|
||||
}
|
||||
return total, total - avail
|
||||
}
|
||||
|
||||
func swapUsedFree(m map[string]uint64) (total, used uint64) {
|
||||
total = m["SwapTotal"]
|
||||
free := m["SwapFree"]
|
||||
if free > total {
|
||||
free = total
|
||||
}
|
||||
return total, total - free
|
||||
}
|
||||
|
||||
// --- cpu --------------------------------------------------------------------
|
||||
|
||||
func parseCPUJiffies(stat string) *cpuSample {
|
||||
for _, line := range strings.Split(stat, "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line[4:])
|
||||
if len(f) < 5 {
|
||||
return nil
|
||||
}
|
||||
var v [8]uint64
|
||||
var total uint64
|
||||
for i := 0; i < len(f) && i < 8; i++ {
|
||||
n, err := strconv.ParseUint(f[i], 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v[i] = n
|
||||
total += n
|
||||
}
|
||||
return &cpuSample{total: total, idle: v[3] + v[4]}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cpuPercent diffs two cumulative samples into a busy percentage. A
|
||||
// missing or non-advancing previous sample reports 0 (the round only
|
||||
// re-baselines) — the source's discipline on both platforms.
|
||||
func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
|
||||
if now == nil {
|
||||
return 0
|
||||
@@ -315,344 +160,14 @@ func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
|
||||
return pct
|
||||
}
|
||||
|
||||
func parseLoadavg(text string) [3]float32 {
|
||||
var out [3]float32
|
||||
f := strings.Fields(text)
|
||||
for i := 0; i < 3 && i < len(f); i++ {
|
||||
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
|
||||
out[i] = float32(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUptime(text string) uint64 {
|
||||
f := strings.Fields(text)
|
||||
if len(f) == 0 {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseFloat(f[0], 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(v)
|
||||
}
|
||||
|
||||
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
|
||||
func cpuInfo(text string) (string, uint32) {
|
||||
name := ""
|
||||
cores := uint32(0)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "processor":
|
||||
cores++
|
||||
case "model name", "Model", "cpu model":
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cores == 0 {
|
||||
cores = uint32(runtime.NumCPU())
|
||||
if cores == 0 {
|
||||
cores = 1
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = armCPUName(text)
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
return name, cores
|
||||
}
|
||||
|
||||
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
|
||||
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
|
||||
func armCPUName(text string) string {
|
||||
var vendor *armVendor
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "CPU implementer":
|
||||
vendor = nil
|
||||
if id, ok := parseHexID(val); ok {
|
||||
for i := range armVendors {
|
||||
if armVendors[i].id == id {
|
||||
vendor = &armVendors[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
case "CPU part":
|
||||
if vendor == nil {
|
||||
continue
|
||||
}
|
||||
name := vendor.name
|
||||
if id, ok := parseHexID(val); ok {
|
||||
for _, p := range vendor.parts {
|
||||
if p.id == id {
|
||||
name = p.name
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(names, "/")
|
||||
}
|
||||
|
||||
func parseHexID(s string) (uint32, bool) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
|
||||
v, err := strconv.ParseUint(s, 16, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(v), true
|
||||
}
|
||||
|
||||
type armPart struct {
|
||||
id uint32
|
||||
name string
|
||||
}
|
||||
type armVendor struct {
|
||||
id uint32
|
||||
name string
|
||||
parts []armPart
|
||||
}
|
||||
|
||||
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
|
||||
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
|
||||
var armVendors = []armVendor{
|
||||
{0x41, "ARM", []armPart{
|
||||
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
|
||||
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
|
||||
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
|
||||
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
|
||||
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
|
||||
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
|
||||
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
|
||||
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
|
||||
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
|
||||
}},
|
||||
{0x48, "HiSilicon", []armPart{
|
||||
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
|
||||
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
|
||||
}},
|
||||
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
|
||||
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
|
||||
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
|
||||
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
|
||||
}
|
||||
|
||||
// --- os / virt ---------------------------------------------------------------
|
||||
|
||||
func osPrettyName() string {
|
||||
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
||||
return strings.Trim(v, "\"'")
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
// detectVirt ports the source's chain: containers first, then hypervisor.
|
||||
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
|
||||
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
|
||||
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
|
||||
// reads as "none", same caveat the source documents for cleared CPUID bits.
|
||||
func detectVirt(mounts, cpuinfo string) string {
|
||||
if _, err := os.Stat("/proc/vz"); err == nil {
|
||||
if _, err := os.Stat("/proc/bc"); err != nil {
|
||||
return "openvz"
|
||||
}
|
||||
}
|
||||
if s := readTrimFile("/run/systemd/container", ""); s != "" {
|
||||
return s
|
||||
}
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return "docker"
|
||||
}
|
||||
if _, err := os.Stat("/run/.containerenv"); err == nil {
|
||||
return "podman"
|
||||
}
|
||||
for _, line := range strings.Split(mounts, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
|
||||
return "lxc"
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/proc/xen"); err == nil {
|
||||
return "xen"
|
||||
}
|
||||
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
|
||||
return "xen"
|
||||
}
|
||||
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
|
||||
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
|
||||
for _, m := range []struct{ word, name string }{
|
||||
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
|
||||
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
|
||||
{"bhyve", "bhyve"}, {"xen", "xen"},
|
||||
} {
|
||||
if strings.Contains(dmi, m.word) {
|
||||
return m.name
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
|
||||
return "kvm"
|
||||
}
|
||||
if strings.Contains(cpuinfo, "hypervisor") {
|
||||
return "vm"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// --- network: counted set, epoch, rates -------------------------------------
|
||||
|
||||
type netCounters struct {
|
||||
name string
|
||||
rx, tx uint64
|
||||
}
|
||||
|
||||
func parseNetDev(text string) []netCounters {
|
||||
var out []netCounters
|
||||
lines := strings.Split(text, "\n")
|
||||
for _, line := range lines {
|
||||
name, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(rest)
|
||||
if len(f) < 16 {
|
||||
continue
|
||||
}
|
||||
rx, err1 := strconv.ParseUint(f[0], 10, 64)
|
||||
tx, err2 := strconv.ParseUint(f[8], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
|
||||
var skipIfacePrefixes = []string{
|
||||
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
|
||||
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
|
||||
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
|
||||
}
|
||||
|
||||
func skipIfaceName(name string) bool {
|
||||
for _, p := range skipIfacePrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
|
||||
// the kernel books their bytes again on the device beneath.
|
||||
func isStackedName(name string) bool {
|
||||
if strings.Contains(name, ".") {
|
||||
return true
|
||||
}
|
||||
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var tunnelLinkTypes = map[string]bool{
|
||||
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
|
||||
}
|
||||
|
||||
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
|
||||
// are already counted on another device. Hardware (a `device` link) exempts
|
||||
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
|
||||
// type with WireGuard but carries real traffic.
|
||||
func countedElsewhere(sysRoot, name string) bool {
|
||||
dev := filepath.Join(sysRoot, name)
|
||||
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
|
||||
devtypeIs := func(set ...string) bool {
|
||||
for _, line := range strings.Split(uevent, "\n") {
|
||||
for _, want := range set {
|
||||
if line == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
|
||||
return true
|
||||
}
|
||||
if entries, err := os.ReadDir(dev); err == nil {
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "lower_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
|
||||
return true
|
||||
}
|
||||
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
|
||||
return true
|
||||
}
|
||||
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
|
||||
}
|
||||
|
||||
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
|
||||
func ifaceCounts(sysRoot, name string) bool {
|
||||
if skipIfaceName(name) || isStackedName(name) {
|
||||
return false
|
||||
}
|
||||
return !countedElsewhere(sysRoot, name)
|
||||
}
|
||||
|
||||
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
|
||||
out := make([]netCounters, 0, len(all))
|
||||
for _, c := range all {
|
||||
if ifaceCounts(sysRoot, c.name) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bootEpoch = "{kernel boot_id}/{FNV-1a-64 of the sorted counted names}".
|
||||
// bootEpoch = "{boot identity}/{FNV-1a-64 of the sorted counted names}".
|
||||
// The master only tests it for equality: any change re-baselines.
|
||||
func bootEpoch(kernelBootID string, names []string) string {
|
||||
func bootEpoch(bootID string, names []string) string {
|
||||
sorted := append([]string(nil), names...)
|
||||
sort.Strings(sorted)
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(strings.Join(sorted, "\n")))
|
||||
return kernelBootID + "/" + hex16(h.Sum64())
|
||||
return bootID + "/" + hex16(h.Sum64())
|
||||
}
|
||||
|
||||
func hex16(v uint64) string {
|
||||
@@ -697,136 +212,6 @@ func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64,
|
||||
return uint64(float64(rx) / secs), uint64(float64(tx) / secs)
|
||||
}
|
||||
|
||||
// --- addresses (scope §1.5) ---------------------------------------------------
|
||||
|
||||
// probeAddresses picks one address per family: candidates come from
|
||||
// non-skip interfaces that are up, are not link-local, prefer public over
|
||||
// private, and for v6 avoid temporary/deprecated/tentative addresses.
|
||||
func probeAddresses() (string, string) {
|
||||
transient := transientV6(readProcFile("net/if_inet6"))
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
type cand struct {
|
||||
ip net.IP
|
||||
transient bool
|
||||
}
|
||||
var held []cand
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
|
||||
continue
|
||||
}
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
continue
|
||||
}
|
||||
if !lendsAddress(ifc.Name, ip) {
|
||||
continue
|
||||
}
|
||||
c := cand{ip: ip}
|
||||
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
|
||||
for _, t := range transient {
|
||||
if t.Equal(ip6) {
|
||||
c.transient = true
|
||||
}
|
||||
}
|
||||
}
|
||||
held = append(held, c)
|
||||
}
|
||||
}
|
||||
pick := func(v6 bool) string {
|
||||
var best *cand
|
||||
for i := range held {
|
||||
c := &held[i]
|
||||
if (c.ip.To4() == nil) != v6 {
|
||||
continue
|
||||
}
|
||||
if best == nil {
|
||||
best = c
|
||||
continue
|
||||
}
|
||||
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
|
||||
if cp != bp {
|
||||
if cp {
|
||||
best = c
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v6 && best.transient && !c.transient {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return ""
|
||||
}
|
||||
return best.ip.String()
|
||||
}
|
||||
return pick(false), pick(true)
|
||||
}
|
||||
|
||||
// lendsAddress mirrors the source: a skipped interface lends no address,
|
||||
// except a WireGuard interface lending a public v6 that is not in
|
||||
// Cloudflare WARP's shared 2606:4700::/32.
|
||||
func lendsAddress(name string, ip net.IP) bool {
|
||||
if !skipIfaceName(name) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
|
||||
ip16 := ip.To16()
|
||||
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// transientV6 parses /proc/net/if_inet6 for addresses carrying
|
||||
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
|
||||
func transientV6(text string) []net.IP {
|
||||
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
|
||||
var out []net.IP
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 6 {
|
||||
continue
|
||||
}
|
||||
flags, err := strconv.ParseUint(f[4], 16, 32)
|
||||
if err != nil || flags&transientFlags == 0 {
|
||||
continue
|
||||
}
|
||||
if len(f[0]) != 32 {
|
||||
continue
|
||||
}
|
||||
ip := make(net.IP, 16)
|
||||
ok := true
|
||||
for i := 0; i < 16; i++ {
|
||||
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
|
||||
if err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
ip[i] = byte(b)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, ip)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isPublicIP mirrors monitor's is_public: v4 excludes RFC1918, CGNAT
|
||||
// 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
|
||||
// v6 is only 2000::/3.
|
||||
@@ -853,133 +238,3 @@ func isPublicIP(ip net.IP) bool {
|
||||
}
|
||||
return ip16[0]&0xe0 == 0x20
|
||||
}
|
||||
|
||||
// --- sockets / processes ------------------------------------------------------
|
||||
|
||||
func connCounts(v4, v6 string) (tcp, udp uint32) {
|
||||
stat := func(text, prefix, key string) uint32 {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line[len(prefix):])
|
||||
for i := 0; i+1 < len(f); i++ {
|
||||
if f[i] == key {
|
||||
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
|
||||
return uint32(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
|
||||
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
|
||||
return tcp, udp
|
||||
}
|
||||
|
||||
func procCount() uint32 {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var n uint32
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
digits := name != ""
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] < '0' || name[i] > '9' {
|
||||
digits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if digits {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- disk (df口径) ---------------------------------------------------------------
|
||||
|
||||
var skipFSTypes = map[string]bool{
|
||||
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
|
||||
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
|
||||
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
|
||||
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
|
||||
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
|
||||
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
|
||||
"fuse": true, "rpc_pipefs": true,
|
||||
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
|
||||
"glusterfs": true, "9p": true,
|
||||
}
|
||||
|
||||
func skipFSType(fstype string) bool {
|
||||
if skipFSTypes[fstype] {
|
||||
return true
|
||||
}
|
||||
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
|
||||
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// realMountPoints ports parse_mounts: only the topmost mount per path,
|
||||
// deduped by source device (zfs by pool), real storage only.
|
||||
func realMountPoints(text string) []string {
|
||||
type row struct{ dev, mount, fstype string }
|
||||
var rows []row
|
||||
top := map[string]int{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row{f[0], f[1], f[2]})
|
||||
top[f[1]] = len(rows) - 1
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for i, r := range rows {
|
||||
if top[r.mount] != i || skipFSType(r.fstype) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
|
||||
continue
|
||||
}
|
||||
key := r.dev
|
||||
if r.fstype == "zfs" {
|
||||
if j := strings.IndexByte(key, '/'); j >= 0 {
|
||||
key = key[:j]
|
||||
}
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// diskUsage sums statfs over the mounts: total = f_blocks, used =
|
||||
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
|
||||
func diskUsage(mounts []string) (total, used uint64) {
|
||||
for _, m := range mounts {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(m, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
bs := uint64(st.Frsize)
|
||||
if bs == 0 {
|
||||
bs = uint64(st.Bsize)
|
||||
}
|
||||
total += st.Blocks * bs
|
||||
if st.Blocks > st.Bfree {
|
||||
used += (st.Blocks - st.Bfree) * bs
|
||||
}
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// This file holds the probe collector's degrade-don't-die plumbing:
|
||||
// the per-source independence discipline as reusable mechanics,
|
||||
// shared by every platform sampler but exercised today by the Windows
|
||||
// sampler (the Linux sampler reads /proc files, whose failure mode is
|
||||
// an error return, not a panic).
|
||||
//
|
||||
// Two layers:
|
||||
//
|
||||
// - source gates: an optional OS facility (a Win32 procedure, say)
|
||||
// is probed once; when it is missing the source is skipped —
|
||||
// zeroed — with a single warning, never re-probed, and never
|
||||
// reached through a call path that panics on a missing export the
|
||||
// way LazyProc.Call does when it must-finds a procedure;
|
||||
// - guarded calls: a panicking source is recovered, zeroed and
|
||||
// logged, so one bad source can never take the agent process (and
|
||||
// every other source with it) down.
|
||||
|
||||
// sourceGate caches one availability probe. The zero value is ready.
|
||||
type sourceGate struct {
|
||||
once sync.Once
|
||||
available bool
|
||||
}
|
||||
|
||||
// enabled runs probe on first use and reports whether the source may
|
||||
// be used. probe returns nil when the facility is present. A failed
|
||||
// probe disables the source for the process lifetime and logs once.
|
||||
func (g *sourceGate) enabled(name string, probe func() error) bool {
|
||||
g.once.Do(func() {
|
||||
if err := probe(); err != nil {
|
||||
logger.Warningf("probe: %s unavailable, source disabled: %v", name, err)
|
||||
return
|
||||
}
|
||||
g.available = true
|
||||
})
|
||||
return g.available
|
||||
}
|
||||
|
||||
// guardedCall runs fn and converts a panic into the zero value plus a
|
||||
// warning, so one failing metrics source degrades alone.
|
||||
func guardedCall[T any](name string, fn func() T) (out T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Warningf("probe: %s panicked, source zeroed: %v", name, r)
|
||||
out = *new(T)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The degrade-don't-die mechanics behind V2.2.1 ①: on the trimmed
|
||||
// Tianyi cloud-PC image the Windows sampler died on its first
|
||||
// collection because a missing iphlpapi export made LazyProc.Call
|
||||
// panic. These tests pin the two layers that replace that failure
|
||||
// mode — a failed availability probe disables the source (probed
|
||||
// once, not per poll), and a panicking source is zeroed, never
|
||||
// propagated.
|
||||
|
||||
func TestSourceGateDisablesFailedProbeOnce(t *testing.T) {
|
||||
var g sourceGate
|
||||
calls := 0
|
||||
probe := func() error {
|
||||
calls++
|
||||
return errors.New("procedure not found")
|
||||
}
|
||||
for i := range 3 {
|
||||
if g.enabled("test-source", probe) {
|
||||
t.Fatalf("call %d: gate enabled despite failed probe", i)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceGateEnablesSuccessfulProbeOnce(t *testing.T) {
|
||||
var g sourceGate
|
||||
calls := 0
|
||||
probe := func() error {
|
||||
calls++
|
||||
return nil
|
||||
}
|
||||
for i := range 3 {
|
||||
if !g.enabled("test-source", probe) {
|
||||
t.Fatalf("call %d: gate disabled despite successful probe", i)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedCallRecoversPanicToZero(t *testing.T) {
|
||||
got := guardedCall("test-source", func() uint64 {
|
||||
panic("Failed to find FreeMibIfTable2 procedure in iphlpapi.dll")
|
||||
})
|
||||
if got != 0 {
|
||||
t.Fatalf("guardedCall after panic = %d, want zero value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedCallPassesValueThrough(t *testing.T) {
|
||||
if got := guardedCall("test-source", func() string { return "ok" }); got != "ok" {
|
||||
t.Fatalf("guardedCall = %q, want %q", got, "ok")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
// probe_linux.go is the Linux sampler for the probe collector: host
|
||||
// Facts and per-heartbeat Metrics read directly from /proc and /sys
|
||||
// with the standard library only. The accounting discipline (see
|
||||
// probe.go) is the source's:
|
||||
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
|
||||
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
|
||||
// - network totals sum only "counted" interfaces, selected by the
|
||||
// three-layer filter below so no byte is counted twice;
|
||||
// - boot epoch = kernel boot_id + fingerprint of the counted set.
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
)
|
||||
|
||||
func sampleFacts() *ProbeFacts {
|
||||
mem := parseMeminfo(readProcFile("meminfo"))
|
||||
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
|
||||
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
|
||||
v4, v6 := probeAddresses()
|
||||
return &ProbeFacts{
|
||||
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
|
||||
OS: osPrettyName(),
|
||||
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
|
||||
Arch: runtime.GOARCH,
|
||||
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
|
||||
CPUName: cpuName,
|
||||
CPUCores: cpuCores,
|
||||
MemTotal: mem["MemTotal"],
|
||||
SwapTotal: mem["SwapTotal"],
|
||||
DiskTotal: diskTotal,
|
||||
AgentVersion: config.GetAgentDisplayVersion(),
|
||||
IPv4: v4,
|
||||
IPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
// collectMetrics takes one full sample. Individual sources fail soft.
|
||||
func collectMetrics() *ProbeMetrics {
|
||||
probeColl.mu.Lock()
|
||||
defer probeColl.mu.Unlock()
|
||||
mem := parseMeminfo(readProcFile("meminfo"))
|
||||
memTotal, memUsed := memUsedFree(mem)
|
||||
swapTotal, swapUsed := swapUsedFree(mem)
|
||||
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
|
||||
allIfaces := parseNetDev(readProcFile("net/dev"))
|
||||
counted := countedInterfaces(allIfaces, sysNetRoot)
|
||||
if pin := statIfacePinValue(); pin != "" {
|
||||
counted = pinnedInterfaces(allIfaces, pin)
|
||||
}
|
||||
var rxTotal, txTotal uint64
|
||||
names := make([]string, 0, len(counted))
|
||||
for _, c := range counted {
|
||||
rxTotal += c.rx
|
||||
txTotal += c.tx
|
||||
names = append(names, c.name)
|
||||
}
|
||||
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
|
||||
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
||||
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
|
||||
return &ProbeMetrics{
|
||||
BootID: bootID,
|
||||
Uptime: parseUptime(readProcFile("uptime")),
|
||||
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
|
||||
Load: parseLoadavg(readProcFile("loadavg")),
|
||||
MemTotal: memTotal,
|
||||
MemUsed: memUsed,
|
||||
SwapTotal: swapTotal,
|
||||
SwapUsed: swapUsed,
|
||||
DiskTotal: diskTotal,
|
||||
DiskUsed: diskUsed,
|
||||
NetRxTotal: rxTotal,
|
||||
NetTxTotal: txTotal,
|
||||
NetRx: rxRate,
|
||||
NetTx: txRate,
|
||||
TCP: tcp,
|
||||
UDP: udp,
|
||||
Procs: procCount(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- file helpers -----------------------------------------------------------
|
||||
|
||||
const sysNetRoot = "/sys/class/net"
|
||||
|
||||
func readProcFile(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("/proc", name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func readTrimFile(path, fallback string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
if s := strings.TrimSpace(string(b)); s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// --- memory (free口径) -------------------------------------------------------
|
||||
|
||||
func parseMeminfo(text string) map[string]uint64 {
|
||||
out := make(map[string]uint64)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(rest)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
kb, err := strconv.ParseUint(f[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[key] = kb * 1024
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
|
||||
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
|
||||
// available above the total, as LXCFS can report, -> total - MemFree).
|
||||
func memUsedFree(m map[string]uint64) (total, used uint64) {
|
||||
total = m["MemTotal"]
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
avail, ok := m["MemAvailable"]
|
||||
if !ok {
|
||||
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
|
||||
}
|
||||
if avail > total {
|
||||
avail = m["MemFree"]
|
||||
}
|
||||
if avail > total {
|
||||
avail = total
|
||||
}
|
||||
return total, total - avail
|
||||
}
|
||||
|
||||
func swapUsedFree(m map[string]uint64) (total, used uint64) {
|
||||
total = m["SwapTotal"]
|
||||
free := m["SwapFree"]
|
||||
if free > total {
|
||||
free = total
|
||||
}
|
||||
return total, total - free
|
||||
}
|
||||
|
||||
// --- cpu --------------------------------------------------------------------
|
||||
|
||||
func parseCPUJiffies(stat string) *cpuSample {
|
||||
for _, line := range strings.Split(stat, "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line[4:])
|
||||
if len(f) < 5 {
|
||||
return nil
|
||||
}
|
||||
var v [8]uint64
|
||||
var total uint64
|
||||
for i := 0; i < len(f) && i < 8; i++ {
|
||||
n, err := strconv.ParseUint(f[i], 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v[i] = n
|
||||
total += n
|
||||
}
|
||||
return &cpuSample{total: total, idle: v[3] + v[4]}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLoadavg(text string) [3]float32 {
|
||||
var out [3]float32
|
||||
f := strings.Fields(text)
|
||||
for i := 0; i < 3 && i < len(f); i++ {
|
||||
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
|
||||
out[i] = float32(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUptime(text string) uint64 {
|
||||
f := strings.Fields(text)
|
||||
if len(f) == 0 {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseFloat(f[0], 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(v)
|
||||
}
|
||||
|
||||
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
|
||||
func cpuInfo(text string) (string, uint32) {
|
||||
name := ""
|
||||
cores := uint32(0)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "processor":
|
||||
cores++
|
||||
case "model name", "Model", "cpu model":
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cores == 0 {
|
||||
cores = uint32(runtime.NumCPU())
|
||||
if cores == 0 {
|
||||
cores = 1
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = armCPUName(text)
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
return name, cores
|
||||
}
|
||||
|
||||
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
|
||||
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
|
||||
func armCPUName(text string) string {
|
||||
var vendor *armVendor
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "CPU implementer":
|
||||
vendor = nil
|
||||
if id, ok := parseHexID(val); ok {
|
||||
for i := range armVendors {
|
||||
if armVendors[i].id == id {
|
||||
vendor = &armVendors[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
case "CPU part":
|
||||
if vendor == nil {
|
||||
continue
|
||||
}
|
||||
name := vendor.name
|
||||
if id, ok := parseHexID(val); ok {
|
||||
for _, p := range vendor.parts {
|
||||
if p.id == id {
|
||||
name = p.name
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(names, "/")
|
||||
}
|
||||
|
||||
func parseHexID(s string) (uint32, bool) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
|
||||
v, err := strconv.ParseUint(s, 16, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(v), true
|
||||
}
|
||||
|
||||
type armPart struct {
|
||||
id uint32
|
||||
name string
|
||||
}
|
||||
type armVendor struct {
|
||||
id uint32
|
||||
name string
|
||||
parts []armPart
|
||||
}
|
||||
|
||||
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
|
||||
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
|
||||
var armVendors = []armVendor{
|
||||
{0x41, "ARM", []armPart{
|
||||
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
|
||||
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
|
||||
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
|
||||
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
|
||||
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
|
||||
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
|
||||
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
|
||||
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
|
||||
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
|
||||
}},
|
||||
{0x48, "HiSilicon", []armPart{
|
||||
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
|
||||
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
|
||||
}},
|
||||
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
|
||||
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
|
||||
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
|
||||
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
|
||||
}
|
||||
|
||||
// --- os / virt ---------------------------------------------------------------
|
||||
|
||||
func osPrettyName() string {
|
||||
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
||||
return strings.Trim(v, "\"'")
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
// detectVirt ports the source's chain: containers first, then hypervisor.
|
||||
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
|
||||
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
|
||||
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
|
||||
// reads as "none", same caveat the source documents for cleared CPUID bits.
|
||||
func detectVirt(mounts, cpuinfo string) string {
|
||||
if _, err := os.Stat("/proc/vz"); err == nil {
|
||||
if _, err := os.Stat("/proc/bc"); err != nil {
|
||||
return "openvz"
|
||||
}
|
||||
}
|
||||
if s := readTrimFile("/run/systemd/container", ""); s != "" {
|
||||
return s
|
||||
}
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return "docker"
|
||||
}
|
||||
if _, err := os.Stat("/run/.containerenv"); err == nil {
|
||||
return "podman"
|
||||
}
|
||||
for _, line := range strings.Split(mounts, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
|
||||
return "lxc"
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/proc/xen"); err == nil {
|
||||
return "xen"
|
||||
}
|
||||
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
|
||||
return "xen"
|
||||
}
|
||||
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
|
||||
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
|
||||
for _, m := range []struct{ word, name string }{
|
||||
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
|
||||
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
|
||||
{"bhyve", "bhyve"}, {"xen", "xen"},
|
||||
} {
|
||||
if strings.Contains(dmi, m.word) {
|
||||
return m.name
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
|
||||
return "kvm"
|
||||
}
|
||||
if strings.Contains(cpuinfo, "hypervisor") {
|
||||
return "vm"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// --- network: counted set, epoch, rates -------------------------------------
|
||||
|
||||
func parseNetDev(text string) []netCounters {
|
||||
var out []netCounters
|
||||
lines := strings.Split(text, "\n")
|
||||
for _, line := range lines {
|
||||
name, rest, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(rest)
|
||||
if len(f) < 16 {
|
||||
continue
|
||||
}
|
||||
rx, err1 := strconv.ParseUint(f[0], 10, 64)
|
||||
tx, err2 := strconv.ParseUint(f[8], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
|
||||
var skipIfacePrefixes = []string{
|
||||
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
|
||||
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
|
||||
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
|
||||
}
|
||||
|
||||
func skipIfaceName(name string) bool {
|
||||
for _, p := range skipIfacePrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
|
||||
// the kernel books their bytes again on the device beneath.
|
||||
func isStackedName(name string) bool {
|
||||
if strings.Contains(name, ".") {
|
||||
return true
|
||||
}
|
||||
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var tunnelLinkTypes = map[string]bool{
|
||||
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
|
||||
}
|
||||
|
||||
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
|
||||
// are already counted on another device. Hardware (a `device` link) exempts
|
||||
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
|
||||
// type with WireGuard but carries real traffic.
|
||||
func countedElsewhere(sysRoot, name string) bool {
|
||||
dev := filepath.Join(sysRoot, name)
|
||||
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
|
||||
devtypeIs := func(set ...string) bool {
|
||||
for _, line := range strings.Split(uevent, "\n") {
|
||||
for _, want := range set {
|
||||
if line == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
|
||||
return true
|
||||
}
|
||||
if entries, err := os.ReadDir(dev); err == nil {
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "lower_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
|
||||
return true
|
||||
}
|
||||
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
|
||||
return true
|
||||
}
|
||||
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
|
||||
}
|
||||
|
||||
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
|
||||
func ifaceCounts(sysRoot, name string) bool {
|
||||
if skipIfaceName(name) || isStackedName(name) {
|
||||
return false
|
||||
}
|
||||
return !countedElsewhere(sysRoot, name)
|
||||
}
|
||||
|
||||
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
|
||||
out := make([]netCounters, 0, len(all))
|
||||
for _, c := range all {
|
||||
if ifaceCounts(sysRoot, c.name) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- addresses (scope §1.5) ---------------------------------------------------
|
||||
|
||||
// probeAddresses picks one address per family: candidates come from
|
||||
// non-skip interfaces that are up, are not link-local, prefer public over
|
||||
// private, and for v6 avoid temporary/deprecated/tentative addresses.
|
||||
func probeAddresses() (string, string) {
|
||||
transient := transientV6(readProcFile("net/if_inet6"))
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
type cand struct {
|
||||
ip net.IP
|
||||
transient bool
|
||||
}
|
||||
var held []cand
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
|
||||
continue
|
||||
}
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
continue
|
||||
}
|
||||
if !lendsAddress(ifc.Name, ip) {
|
||||
continue
|
||||
}
|
||||
c := cand{ip: ip}
|
||||
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
|
||||
for _, t := range transient {
|
||||
if t.Equal(ip6) {
|
||||
c.transient = true
|
||||
}
|
||||
}
|
||||
}
|
||||
held = append(held, c)
|
||||
}
|
||||
}
|
||||
pick := func(v6 bool) string {
|
||||
var best *cand
|
||||
for i := range held {
|
||||
c := &held[i]
|
||||
if (c.ip.To4() == nil) != v6 {
|
||||
continue
|
||||
}
|
||||
if best == nil {
|
||||
best = c
|
||||
continue
|
||||
}
|
||||
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
|
||||
if cp != bp {
|
||||
if cp {
|
||||
best = c
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v6 && best.transient && !c.transient {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return ""
|
||||
}
|
||||
return best.ip.String()
|
||||
}
|
||||
return pick(false), pick(true)
|
||||
}
|
||||
|
||||
// lendsAddress mirrors the source: a skipped interface lends no address,
|
||||
// except a WireGuard interface lending a public v6 that is not in
|
||||
// Cloudflare WARP's shared 2606:4700::/32.
|
||||
func lendsAddress(name string, ip net.IP) bool {
|
||||
if !skipIfaceName(name) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
|
||||
ip16 := ip.To16()
|
||||
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// transientV6 parses /proc/net/if_inet6 for addresses carrying
|
||||
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
|
||||
func transientV6(text string) []net.IP {
|
||||
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
|
||||
var out []net.IP
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 6 {
|
||||
continue
|
||||
}
|
||||
flags, err := strconv.ParseUint(f[4], 16, 32)
|
||||
if err != nil || flags&transientFlags == 0 {
|
||||
continue
|
||||
}
|
||||
if len(f[0]) != 32 {
|
||||
continue
|
||||
}
|
||||
ip := make(net.IP, 16)
|
||||
ok := true
|
||||
for i := 0; i < 16; i++ {
|
||||
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
|
||||
if err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
ip[i] = byte(b)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, ip)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- sockets / processes ------------------------------------------------------
|
||||
|
||||
func connCounts(v4, v6 string) (tcp, udp uint32) {
|
||||
stat := func(text, prefix, key string) uint32 {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line[len(prefix):])
|
||||
for i := 0; i+1 < len(f); i++ {
|
||||
if f[i] == key {
|
||||
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
|
||||
return uint32(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
|
||||
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
|
||||
return tcp, udp
|
||||
}
|
||||
|
||||
func procCount() uint32 {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var n uint32
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
digits := name != ""
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] < '0' || name[i] > '9' {
|
||||
digits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if digits {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- disk (df口径) ---------------------------------------------------------------
|
||||
|
||||
var skipFSTypes = map[string]bool{
|
||||
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
|
||||
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
|
||||
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
|
||||
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
|
||||
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
|
||||
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
|
||||
"fuse": true, "rpc_pipefs": true,
|
||||
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
|
||||
"glusterfs": true, "9p": true,
|
||||
}
|
||||
|
||||
func skipFSType(fstype string) bool {
|
||||
if skipFSTypes[fstype] {
|
||||
return true
|
||||
}
|
||||
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
|
||||
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// realMountPoints ports parse_mounts: only the topmost mount per path,
|
||||
// deduped by source device (zfs by pool), real storage only.
|
||||
func realMountPoints(text string) []string {
|
||||
type row struct{ dev, mount, fstype string }
|
||||
var rows []row
|
||||
top := map[string]int{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row{f[0], f[1], f[2]})
|
||||
top[f[1]] = len(rows) - 1
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for i, r := range rows {
|
||||
if top[r.mount] != i || skipFSType(r.fstype) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
|
||||
continue
|
||||
}
|
||||
key := r.dev
|
||||
if r.fstype == "zfs" {
|
||||
if j := strings.IndexByte(key, '/'); j >= 0 {
|
||||
key = key[:j]
|
||||
}
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// diskUsage sums statfs over the mounts: total = f_blocks, used =
|
||||
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
|
||||
func diskUsage(mounts []string) (total, used uint64) {
|
||||
for _, m := range mounts {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(m, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
bs := uint64(st.Frsize)
|
||||
if bs == 0 {
|
||||
bs = uint64(st.Bsize)
|
||||
}
|
||||
total += st.Blocks * bs
|
||||
if st.Blocks > st.Bfree {
|
||||
used += (st.Blocks - st.Bfree) * bs
|
||||
}
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// V2.1.0 probe-only tests: the mode gates both core branches of the
|
||||
// config poll (no fetch, no start, no switch latch), retires a core
|
||||
// that was running when the mode turns on, and self-heals back to
|
||||
// the core when the mode turns off again.
|
||||
|
||||
// probeMaster is a fake master whose config response is assembled
|
||||
// from a switchable probe_only flag plus the usual sing-box payload.
|
||||
type probeMaster struct {
|
||||
probeOnly atomic.Bool
|
||||
trafficHit atomic.Int32
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newProbeMaster(t *testing.T) *probeMaster {
|
||||
t.Helper()
|
||||
m := &probeMaster{}
|
||||
sum := sha256.Sum256([]byte(sbCfgA))
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
body := map[string]any{
|
||||
"singbox_config": json.RawMessage(sbCfgA),
|
||||
"singbox_config_hash": hex.EncodeToString(sum[:]),
|
||||
"probe_tasks": []any{},
|
||||
// The real master always sends the flag (only a pre-V2.1.0
|
||||
// master omits it); the fixture mirrors that.
|
||||
"probe_only": m.probeOnly.Load(),
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
})
|
||||
mux.HandleFunc("/panel/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
m.trafficHit.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
})
|
||||
m.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(m.srv.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *probeMaster) agent(cfg *Config) *Agent {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
cfg.MasterURL = m.srv.URL
|
||||
cfg.Token = "tok"
|
||||
cfg.NodeName = "n"
|
||||
return New(cfg)
|
||||
}
|
||||
|
||||
// The master's flag alone (no local preset) must gate the poll: the
|
||||
// served sing-box payload is ignored entirely.
|
||||
func TestProbeOnlyMasterFlagGatesApply(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
m := newProbeMaster(t)
|
||||
m.probeOnly.Store(true)
|
||||
a := m.agent(nil)
|
||||
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
|
||||
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("fetchAndApplyConfig: %v", err)
|
||||
}
|
||||
if !a.probeOnlyActive() {
|
||||
t.Fatal("probe-only must be active from the master flag")
|
||||
}
|
||||
if a.sbSwitched {
|
||||
t.Fatal("switchover latched under probe-only")
|
||||
}
|
||||
if a.singboxRunning() {
|
||||
t.Fatal("sing-box started under probe-only")
|
||||
}
|
||||
if a.lastSingboxHash != "" {
|
||||
t.Fatalf("lastSingboxHash = %q, want untouched (empty)", a.lastSingboxHash)
|
||||
}
|
||||
if _, err := os.Stat(singboxConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("applied config file exists under probe-only (stat err = %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The local agent.json preset gates even against a master that
|
||||
// still serves a payload (and predates the flag): with no core
|
||||
// binary on disk, not even a core fetch may be attempted.
|
||||
func TestProbeOnlyLocalPresetSkipsCoreFetch(t *testing.T) {
|
||||
binDir := t.TempDir() // deliberately no fake sing-box binary
|
||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||
|
||||
var hits atomic.Int32
|
||||
coreSrv := fakeCoreServer(t, &hits, http.StatusOK)
|
||||
defer coreSrv.Close()
|
||||
useCoreBase(t, coreSrv.URL)
|
||||
|
||||
m := newProbeMaster(t) // probe_only absent from its responses
|
||||
a := m.agent(&Config{ProbeOnly: true})
|
||||
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
|
||||
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("fetchAndApplyConfig: %v", err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("core fetch attempted %d times under local probe-only preset", hits.Load())
|
||||
}
|
||||
if a.sbSwitched || a.singboxRunning() {
|
||||
t.Fatal("core engaged under local probe-only preset")
|
||||
}
|
||||
}
|
||||
|
||||
// Turning the flag on while sing-box is running retires the core
|
||||
// exactly once; turning it off again reactivates the core from the
|
||||
// unchanged hash (the poll's not-running path), without the
|
||||
// bookkeeping having been dirtied in between.
|
||||
func TestProbeOnlyRetiresRunningSingboxAndHealsBack(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
m := newProbeMaster(t)
|
||||
a := m.agent(nil)
|
||||
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
|
||||
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("initial fetch: %v", err)
|
||||
}
|
||||
if !a.singboxRunning() || !a.sbSwitched {
|
||||
t.Fatal("precondition: sing-box must be running after the normal poll")
|
||||
}
|
||||
hashBefore := a.lastSingboxHash
|
||||
|
||||
m.probeOnly.Store(true)
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("probe-only fetch: %v", err)
|
||||
}
|
||||
if a.singboxRunning() {
|
||||
t.Fatal("sing-box must be retired when probe-only turns on")
|
||||
}
|
||||
if !a.sbSwitched {
|
||||
t.Fatal("sbSwitched must stay latched (bookkeeping untouched by probe-only)")
|
||||
}
|
||||
if a.lastSingboxHash != hashBefore {
|
||||
t.Fatalf("lastSingboxHash dirtied: %q -> %q", hashBefore, a.lastSingboxHash)
|
||||
}
|
||||
// A second probe-only poll is a no-op, not an error.
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("second probe-only fetch: %v", err)
|
||||
}
|
||||
|
||||
m.probeOnly.Store(false)
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("heal-back fetch: %v", err)
|
||||
}
|
||||
if !a.singboxRunning() {
|
||||
t.Fatal("sing-box must restart once probe-only turns off (hash unchanged)")
|
||||
}
|
||||
}
|
||||
|
||||
// Same retirement for a legacy xray core that predates the
|
||||
// switchover: probe-only stops it and does not start sing-box.
|
||||
func TestProbeOnlyRetiresLegacyXray(t *testing.T) {
|
||||
binDir := setupFakeSingbox(t)
|
||||
setupFakeXray(t, binDir)
|
||||
m := newProbeMaster(t)
|
||||
m.probeOnly.Store(true)
|
||||
a := m.agent(nil)
|
||||
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
|
||||
startLegacyXray(t, a)
|
||||
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
t.Fatalf("fetchAndApplyConfig: %v", err)
|
||||
}
|
||||
if a.xrayProc != nil {
|
||||
t.Fatal("xray core must be retired under probe-only")
|
||||
}
|
||||
if a.singboxRunning() || a.sbSwitched {
|
||||
t.Fatal("sing-box must not engage under probe-only")
|
||||
}
|
||||
}
|
||||
|
||||
// The traffic cycle is an explicit no-op under probe-only: even
|
||||
// with a core process somehow alive, nothing is collected or posted.
|
||||
func TestProbeOnlyTrafficCycleIsNoop(t *testing.T) {
|
||||
setupFakeSingbox(t)
|
||||
m := newProbeMaster(t)
|
||||
a := m.agent(&Config{ProbeOnly: true})
|
||||
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
|
||||
|
||||
// Put a core under the agent directly (bypassing the poll gate)
|
||||
// to prove the traffic cycle itself refuses to touch it.
|
||||
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
|
||||
t.Fatalf("applySingboxConfig: %v", err)
|
||||
}
|
||||
if !a.singboxRunning() {
|
||||
t.Fatal("precondition: fake sing-box must be running")
|
||||
}
|
||||
if err := a.collectAndReportTraffic(); err != nil {
|
||||
t.Fatalf("collectAndReportTraffic under probe-only: %v", err)
|
||||
}
|
||||
if n := m.trafficHit.Load(); n != 0 {
|
||||
t.Fatalf("traffic posted %d times under probe-only", n)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
//go:build linux
|
||||
|
||||
// This file tests the Linux sampler's /proc and /sys parsers
|
||||
// (probe_linux.go); the shared machinery has its own coverage and the
|
||||
// Windows sampler's pure helpers live in probe_windows_test.go.
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
// probe_windows.go is the Windows sampler for the probe collector,
|
||||
// feeding the same Facts/Metrics contract and accounting discipline
|
||||
// as the Linux sampler (see probe.go) from the Win32 surface:
|
||||
//
|
||||
// - CPU%: GetSystemTimes differenced through the shared cpuPercent
|
||||
// (kernel time includes idle, exactly like /proc/stat jiffies);
|
||||
// - memory: GlobalMemoryStatusEx, used = TotalPhys - AvailPhys
|
||||
// (the free(1) "available" column's spirit); swap is the pagefile
|
||||
// pair (TotalPageFile/AvailPageFile);
|
||||
// - disk: fixed drives only (GetDriveType == DRIVE_FIXED), summed
|
||||
// like df: used = total - free bytes (GetDiskFreeSpaceEx's
|
||||
// TotalFree, NOT the caller-available figure, which discounts
|
||||
// quotas the way f_bavail discounts the ext reserve);
|
||||
// - network: GetIfTable2 rows filtered to OperStatus Up, ifType
|
||||
// Ethernet (6) or IEEE80211 (71), non-loopback. This approximates
|
||||
// the Linux three-layer filter: Windows exposes no stacking
|
||||
// topology in the MIB, but tunnel/virtual adapters report their
|
||||
// own ifTypes (tunnel 131, loopback 24, ...), so an allowlist of
|
||||
// physical media lands on the same counted set in practice. The
|
||||
// cost of the difference: a physical-medium bond/virtual switch
|
||||
// vNIC presenting as Ethernet IS counted here even where Linux
|
||||
// layer 3 would have skipped it;
|
||||
// - boot identity: the registry MachineGuid stands in for the
|
||||
// kernel boot_id; the counted-set FNV fingerprint is shared, so a
|
||||
// NIC change re-baselines exactly like on Linux;
|
||||
// - uptime: GetTickCount64 (via x/sys' DurationSinceBoot wrapper);
|
||||
// - load averages, TCP/UDP connection counts have no exported
|
||||
// x/sys/windows (v0.47.0) source — GetSystemTimes-style raw
|
||||
// counters exist for CPU/memory, but the package exports no
|
||||
// GetTcpTable2/GetUdpTable2 wrapper, so per the independent-
|
||||
// degradation discipline Load stays zero and TCP/UDP stay 0
|
||||
// until a wrapper exists;
|
||||
// - every call fails soft to zeros, like an unreadable /proc file.
|
||||
// Optional procedures are resolved through source gates
|
||||
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
|
||||
// missing export, and trimmed images do ship DLLs without the
|
||||
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
|
||||
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
|
||||
// collection (V2.2.1 ①). A missing procedure now disables just
|
||||
// its source, and collectMetrics additionally guards each source
|
||||
// so a panic anywhere degrades that source alone.
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// kernel32 procs x/sys/windows v0.47.0 does not wrap, plus the
|
||||
// iphlpapi pair this file calls directly (gated below, so x/sys'
|
||||
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
|
||||
// missing export — is deliberately not used).
|
||||
var (
|
||||
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
|
||||
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
|
||||
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
|
||||
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
|
||||
)
|
||||
|
||||
// Availability gates for the optional procedures above. A LazyProc
|
||||
// must never be Call()ed before its gate has proven the export
|
||||
// exists: Call must-finds, so a missing procedure is a process-killing
|
||||
// panic, not an error.
|
||||
var (
|
||||
gateSystemTimes sourceGate
|
||||
gateMemoryStatus sourceGate
|
||||
gateIfTable sourceGate
|
||||
)
|
||||
|
||||
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
|
||||
// the export WITHOUT the mustFind panic baked into Call/Addr.
|
||||
func procProbe(p *windows.LazyProc) func() error {
|
||||
return func() error { return p.Find() }
|
||||
}
|
||||
|
||||
func sampleFacts() *ProbeFacts {
|
||||
memTotal, _, swapTotal, _ := winMemory()
|
||||
diskTotal, _ := winDiskUsage()
|
||||
v4, v6 := winAddresses()
|
||||
return &ProbeFacts{
|
||||
Hostname: winHostname(),
|
||||
OS: winOSName(),
|
||||
Kernel: winKernelBuild(),
|
||||
Arch: runtime.GOARCH,
|
||||
Virt: winVirt(),
|
||||
CPUName: winCPUName(),
|
||||
CPUCores: uint32(runtime.NumCPU()),
|
||||
MemTotal: memTotal,
|
||||
SwapTotal: swapTotal,
|
||||
DiskTotal: diskTotal,
|
||||
AgentVersion: config.GetAgentDisplayVersion(),
|
||||
IPv4: v4,
|
||||
IPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
// memSnapshot / diskSnapshot pack the multi-value samplers so each
|
||||
// can run as one guarded source.
|
||||
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
|
||||
type diskSnapshot struct{ total, used uint64 }
|
||||
|
||||
func collectMetrics() *ProbeMetrics {
|
||||
probeColl.mu.Lock()
|
||||
defer probeColl.mu.Unlock()
|
||||
// Every source runs guarded: a panic in one (a Win32 surface the
|
||||
// gates did not foresee) zeroes that source alone instead of
|
||||
// killing the agent on its first collection.
|
||||
mem := guardedCall("memory", func() memSnapshot {
|
||||
total, used, swapTotal, swapUsed := winMemory()
|
||||
return memSnapshot{total, used, swapTotal, swapUsed}
|
||||
})
|
||||
disk := guardedCall("disk", func() diskSnapshot {
|
||||
total, used := winDiskUsage()
|
||||
return diskSnapshot{total, used}
|
||||
})
|
||||
rows := guardedCall("interfaces", winIfaceRows)
|
||||
all := make([]netCounters, 0, len(rows))
|
||||
counted := make([]netCounters, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
c := netCounters{name: r.name, rx: r.rx, tx: r.tx}
|
||||
all = append(all, c)
|
||||
if winIfaceCounts(r) {
|
||||
counted = append(counted, c)
|
||||
}
|
||||
}
|
||||
if pin := statIfacePinValue(); pin != "" {
|
||||
counted = pinnedInterfaces(all, pin)
|
||||
}
|
||||
var rxTotal, txTotal uint64
|
||||
names := make([]string, 0, len(counted))
|
||||
for _, c := range counted {
|
||||
rxTotal += c.rx
|
||||
txTotal += c.tx
|
||||
names = append(names, c.name)
|
||||
}
|
||||
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
||||
return &ProbeMetrics{
|
||||
BootID: bootEpoch(winMachineGUID(), names),
|
||||
Uptime: guardedCall("uptime", func() uint64 {
|
||||
return uint64(windows.DurationSinceBoot().Seconds())
|
||||
}),
|
||||
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
|
||||
Load: [3]float32{},
|
||||
MemTotal: mem.total,
|
||||
MemUsed: mem.used,
|
||||
SwapTotal: mem.swapTotal,
|
||||
SwapUsed: mem.swapUsed,
|
||||
DiskTotal: disk.total,
|
||||
DiskUsed: disk.used,
|
||||
NetRxTotal: rxTotal,
|
||||
NetTxTotal: txTotal,
|
||||
NetRx: rxRate,
|
||||
NetTx: txRate,
|
||||
TCP: 0, // no exported x/sys wrapper (see header)
|
||||
UDP: 0, // no exported x/sys wrapper (see header)
|
||||
Procs: guardedCall("processes", winProcCount),
|
||||
}
|
||||
}
|
||||
|
||||
// --- registry helpers --------------------------------------------------------
|
||||
|
||||
func regGetString(root registry.Key, path, name string) string {
|
||||
k, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer k.Close()
|
||||
v, _, err := k.GetStringValue(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func winMachineGUID() string {
|
||||
return regGetString(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, "MachineGuid")
|
||||
}
|
||||
|
||||
func winOSName() string {
|
||||
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
|
||||
name := regGetString(registry.LOCAL_MACHINE, key, "ProductName")
|
||||
if name == "" {
|
||||
return "Windows"
|
||||
}
|
||||
// DisplayVersion ("23H2") disambiguates: ProductName still says
|
||||
// "Windows 10" on most Windows 11 installs.
|
||||
if dv := regGetString(registry.LOCAL_MACHINE, key, "DisplayVersion"); dv != "" {
|
||||
name += " " + dv
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func winKernelBuild() string {
|
||||
const key = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
|
||||
if b := regGetString(registry.LOCAL_MACHINE, key, "CurrentBuild"); b != "" {
|
||||
return b
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func winCPUName() string {
|
||||
if n := regGetString(registry.LOCAL_MACHINE,
|
||||
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString"); n != "" {
|
||||
return n
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// winVirt is best-effort: the BIOS strings Windows itself surfaces.
|
||||
// No match reads "unknown" (Windows guests of a hypervisor that hides
|
||||
// them are indistinguishable from bare metal this way).
|
||||
func winVirt() string {
|
||||
const bios = `HARDWARE\DESCRIPTION\System\BIOS`
|
||||
hay := strings.ToLower(regGetString(registry.LOCAL_MACHINE, bios, "SystemManufacturer") +
|
||||
" " + regGetString(registry.LOCAL_MACHINE, bios, "SystemProductName"))
|
||||
for _, m := range []struct{ word, name string }{
|
||||
{"vmware", "vmware"}, {"virtualbox", "virtualbox"}, {"qemu", "kvm"},
|
||||
{"kvm", "kvm"}, {"xen", "xen"}, {"virtual machine", "hyper-v"},
|
||||
{"amazon ec2", "kvm"}, {"google", "kvm"}, {"bochs", "bochs"},
|
||||
} {
|
||||
if strings.Contains(hay, m.word) {
|
||||
return m.name
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func winHostname() string {
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// --- cpu / memory --------------------------------------------------------------
|
||||
|
||||
// filetime100ns flattens a FILETIME (100ns ticks) for the shared differ.
|
||||
func filetime100ns(ft windows.Filetime) uint64 {
|
||||
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
|
||||
}
|
||||
|
||||
// winCPUSample reads the cumulative system times as one cpuSample:
|
||||
// total = kernel + user (kernel already includes idle, like jiffies).
|
||||
func winCPUSample() *cpuSample {
|
||||
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
|
||||
return nil
|
||||
}
|
||||
var idle, kernel, user windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idle)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)))
|
||||
if r == 0 {
|
||||
return nil
|
||||
}
|
||||
return &cpuSample{
|
||||
total: filetime100ns(kernel) + filetime100ns(user),
|
||||
idle: filetime100ns(idle),
|
||||
}
|
||||
}
|
||||
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
// usedFromTotalAvail is the used-column discipline as a pure
|
||||
// function: used = total - avail, saturating at 0.
|
||||
func usedFromTotalAvail(total, avail uint64) uint64 {
|
||||
if avail > total {
|
||||
return 0
|
||||
}
|
||||
return total - avail
|
||||
}
|
||||
|
||||
func winMemory() (total, used, swapTotal, swapUsed uint64) {
|
||||
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
var ms memoryStatusEx
|
||||
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
|
||||
if r == 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
return ms.TotalPhys, usedFromTotalAvail(ms.TotalPhys, ms.AvailPhys),
|
||||
ms.TotalPageFile, usedFromTotalAvail(ms.TotalPageFile, ms.AvailPageFile)
|
||||
}
|
||||
|
||||
// --- disk (df口径) -----------------------------------------------------------
|
||||
|
||||
// driveSpace is one fixed drive's GetDiskFreeSpaceEx result.
|
||||
type driveSpace struct{ total, free uint64 }
|
||||
|
||||
// sumDriveSpace aggregates fixed drives the df way: used = total -
|
||||
// free (the caller-available figure is deliberately not consulted).
|
||||
func sumDriveSpace(ds []driveSpace) (total, used uint64) {
|
||||
for _, d := range ds {
|
||||
total += d.total
|
||||
used += usedFromTotalAvail(d.total, d.free)
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
// splitDriveStrings decodes the GetLogicalDriveStrings multi-string
|
||||
// buffer (NUL-separated roots, double-NUL terminated). Pure, so the
|
||||
// layout is unit-testable off-device.
|
||||
func splitDriveStrings(buf []uint16) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i, c := range buf {
|
||||
if c != 0 {
|
||||
continue
|
||||
}
|
||||
if i == start {
|
||||
break // double NUL: end of the list
|
||||
}
|
||||
out = append(out, windows.UTF16ToString(buf[start:i]))
|
||||
start = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fixedDriveRoots lists roots ("C:\") of DRIVE_FIXED drives.
|
||||
func fixedDriveRoots() []string {
|
||||
n, err := windows.GetLogicalDriveStrings(0, nil)
|
||||
if err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
buf := make([]uint16, n)
|
||||
n, err = windows.GetLogicalDriveStrings(n, &buf[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var roots []string
|
||||
for _, s := range splitDriveStrings(buf[:n]) {
|
||||
p, err := windows.UTF16PtrFromString(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if windows.GetDriveType(p) == windows.DRIVE_FIXED {
|
||||
roots = append(roots, s)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func winDiskUsage() (total, used uint64) {
|
||||
var spaces []driveSpace
|
||||
for _, root := range fixedDriveRoots() {
|
||||
p, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var freeToCaller, totalBytes, totalFree uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(p, &freeToCaller, &totalBytes, &totalFree); err != nil {
|
||||
continue
|
||||
}
|
||||
spaces = append(spaces, driveSpace{total: totalBytes, free: totalFree})
|
||||
}
|
||||
return sumDriveSpace(spaces)
|
||||
}
|
||||
|
||||
// --- network: counted set ------------------------------------------------------
|
||||
|
||||
// winIfaceRow is the filter-relevant slice of one MIB_IF_ROW2.
|
||||
type winIfaceRow struct {
|
||||
name string
|
||||
ifType uint32
|
||||
operUp bool
|
||||
rx, tx uint64
|
||||
}
|
||||
|
||||
// winIfaceCounts applies the counted-set rule to one row (see the
|
||||
// file header for how it approximates the Linux three-layer filter).
|
||||
func winIfaceCounts(r winIfaceRow) bool {
|
||||
if !r.operUp {
|
||||
return false
|
||||
}
|
||||
if r.ifType == windows.IF_TYPE_SOFTWARE_LOOPBACK {
|
||||
return false
|
||||
}
|
||||
return r.ifType == windows.IF_TYPE_ETHERNET_CSMACD ||
|
||||
r.ifType == windows.IF_TYPE_IEEE80211
|
||||
}
|
||||
|
||||
// winIfaceRows reads the full MIB_IF_TABLE2 once; collectMetrics
|
||||
// derives both the all-interfaces list (for the operator pin) and the
|
||||
// counted set from it.
|
||||
func winIfaceRows() []winIfaceRow {
|
||||
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
|
||||
// without FreeMibIfTable2 would leak the system-allocated table
|
||||
// on every poll, and either export missing must degrade the
|
||||
// source to zero rows instead of panicking — the trimmed Tianyi
|
||||
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
|
||||
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
|
||||
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
|
||||
if err := procGetIfTable2Ex.Find(); err != nil {
|
||||
return err
|
||||
}
|
||||
return procFreeMibIfTable2.Find()
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
var table *windows.MibIfTable2
|
||||
r, _, _ := procGetIfTable2Ex.Call(
|
||||
uintptr(windows.MibIfTableNormal),
|
||||
uintptr(unsafe.Pointer(&table)))
|
||||
if r != 0 || table == nil {
|
||||
return nil
|
||||
}
|
||||
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
|
||||
n := int(table.NumEntries)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := unsafe.Slice(&table.Table[0], n)
|
||||
out := make([]winIfaceRow, 0, n)
|
||||
for i := range rows {
|
||||
r := &rows[i]
|
||||
out = append(out, winIfaceRow{
|
||||
name: windows.UTF16ToString(r.Alias[:]),
|
||||
ifType: r.Type,
|
||||
operUp: r.OperStatus == windows.IfOperStatusUp,
|
||||
rx: r.InOctets,
|
||||
tx: r.OutOctets,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- processes -----------------------------------------------------------------
|
||||
|
||||
func winProcCount() uint32 {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
var pe windows.ProcessEntry32
|
||||
pe.Size = uint32(unsafe.Sizeof(pe))
|
||||
if err := windows.Process32First(snap, &pe); err != nil {
|
||||
return 0
|
||||
}
|
||||
var n uint32 = 1
|
||||
for {
|
||||
if err := windows.Process32Next(snap, &pe); err != nil {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- addresses -------------------------------------------------------------------
|
||||
|
||||
// winAddresses picks one address per family from up, non-loopback
|
||||
// interfaces: public preferred over private, like the Linux picker —
|
||||
// only without its /proc inputs (no operstate file, no temporary-
|
||||
// address flags; net.Interfaces' own flags do the loopback cut).
|
||||
func winAddresses() (string, string) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var cands []net.IP
|
||||
for _, ifc := range ifaces {
|
||||
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, ip)
|
||||
}
|
||||
}
|
||||
pick := func(v6 bool) string {
|
||||
var best net.IP
|
||||
for _, ip := range cands {
|
||||
if (ip.To4() == nil) != v6 {
|
||||
continue
|
||||
}
|
||||
if best == nil {
|
||||
best = ip
|
||||
continue
|
||||
}
|
||||
if isPublicIP(ip) && !isPublicIP(best) {
|
||||
best = ip
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return ""
|
||||
}
|
||||
return best.String()
|
||||
}
|
||||
return pick(false), pick(true)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The Windows sampler's pure helpers: the Win32 calls themselves need
|
||||
// a real machine, but the accounting math layered on their results is
|
||||
// pinned here.
|
||||
|
||||
func TestFiletime100nsAndCPUSampleDiff(t *testing.T) {
|
||||
lo := windows.Filetime{LowDateTime: 0x00000005, HighDateTime: 0x00000002}
|
||||
if got := filetime100ns(lo); got != 0x200000005 {
|
||||
t.Fatalf("filetime100ns = %#x, want 0x200000005", got)
|
||||
}
|
||||
// The shared differ over Windows-style counters (kernel includes
|
||||
// idle): idle advanced by the full window -> 0% busy; then a
|
||||
// window where only a quarter of the time is idle -> 75%.
|
||||
c := &probeCollector{}
|
||||
first := &cpuSample{total: 1000, idle: 400}
|
||||
if got := c.cpuPercent(first); got != 0 {
|
||||
t.Fatalf("first sample = %v, want 0 (baseline only)", got)
|
||||
}
|
||||
idleAll := &cpuSample{total: 2000, idle: 1400}
|
||||
if got := c.cpuPercent(idleAll); got != 0 {
|
||||
t.Fatalf("all-idle window = %v, want 0", got)
|
||||
}
|
||||
busy := &cpuSample{total: 3000, idle: 1650}
|
||||
if got := c.cpuPercent(busy); got != 75 {
|
||||
t.Fatalf("busy window = %v, want 75", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsedFromTotalAvail(t *testing.T) {
|
||||
if got := usedFromTotalAvail(100, 30); got != 70 {
|
||||
t.Fatalf("used = %d, want 70", got)
|
||||
}
|
||||
if got := usedFromTotalAvail(100, 100); got != 0 {
|
||||
t.Fatalf("used = %d, want 0", got)
|
||||
}
|
||||
if got := usedFromTotalAvail(100, 130); got != 0 {
|
||||
t.Fatalf("used saturates, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumDriveSpaceDfSemantics(t *testing.T) {
|
||||
total, used := sumDriveSpace([]driveSpace{
|
||||
{total: 1000, free: 250},
|
||||
{total: 2000, free: 2000},
|
||||
{total: 500, free: 900}, // inconsistent source clamps to full
|
||||
})
|
||||
if total != 3500 || used != 1250 {
|
||||
t.Fatalf("sumDriveSpace = %d/%d, want 3500/1250", total, used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDriveStrings(t *testing.T) {
|
||||
mk := func(ss ...string) []uint16 {
|
||||
var out []uint16
|
||||
for _, s := range ss {
|
||||
for _, r := range s {
|
||||
out = append(out, uint16(r))
|
||||
}
|
||||
out = append(out, 0)
|
||||
}
|
||||
return append(out, 0)
|
||||
}
|
||||
got := splitDriveStrings(mk(`C:\`, `D:\`))
|
||||
if len(got) != 2 || got[0] != `C:\` || got[1] != `D:\` {
|
||||
t.Fatalf("splitDriveStrings = %q, want [C:\\ D:\\]", got)
|
||||
}
|
||||
if got := splitDriveStrings(mk()); len(got) != 0 {
|
||||
t.Fatalf("empty buffer = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinIfaceCountsRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
row winIfaceRow
|
||||
want bool
|
||||
}{
|
||||
{"up ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: true}, true},
|
||||
{"up wifi", winIfaceRow{ifType: windows.IF_TYPE_IEEE80211, operUp: true}, true},
|
||||
{"down ethernet", winIfaceRow{ifType: windows.IF_TYPE_ETHERNET_CSMACD, operUp: false}, false},
|
||||
{"loopback", winIfaceRow{ifType: windows.IF_TYPE_SOFTWARE_LOOPBACK, operUp: true}, false},
|
||||
{"tunnel", winIfaceRow{ifType: 131, operUp: true}, false},
|
||||
{"ppp", winIfaceRow{ifType: 23, operUp: true}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := winIfaceCounts(tc.row); got != tc.want {
|
||||
t.Errorf("%s: winIfaceCounts = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -129,7 +151,12 @@ func (a *Agent) runUpdate(intent updateIntent) {
|
||||
fail(fmt.Errorf("locate own binary: %w", err))
|
||||
return
|
||||
}
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
// Agent/panel split (phase 1): the agent downloads the standalone
|
||||
// agent asset, not the panel binary. An agent build predating the
|
||||
// split still asks for the panel asset (AssetName) — the two are
|
||||
// interchangeable at the same path, so each generation converges
|
||||
// on the form its own build requests.
|
||||
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
@@ -167,7 +194,16 @@ func (a *Agent) runUpdate(intent updateIntent) {
|
||||
// Best effort: flush the "swapped" report before the image is replaced
|
||||
// (success is ultimately proven by the new version's telemetry).
|
||||
_ = a.sendHeartbeat()
|
||||
// Stop BOTH managed cores before replacing the image (ITEM-P3
|
||||
// rider). Stopping only xray orphaned a running sing-box: the
|
||||
// orphan kept the inbound ports bound and serving the old config
|
||||
// while the new image's own core died on bind every poll cycle,
|
||||
// and no later config change could reach it. On exec failure the
|
||||
// rollback below restores the old binary in this same process;
|
||||
// the cores stay down only until the next config poll, whose
|
||||
// hash-current-but-core-down path re-applies and restarts them.
|
||||
a.stopXray()
|
||||
a.stopSingbox()
|
||||
if err := execSelf(exePath); err != nil {
|
||||
// The exec failed before replacing the image: roll the old binary
|
||||
// back into place and keep running it.
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -12,9 +13,25 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
)
|
||||
|
||||
// nextAgentTarget returns an intent target one patch above the test
|
||||
// binary's own agent-line version. The update tests must ride the
|
||||
// agent line wherever it currently stands (A2.3.0 since the version
|
||||
// decoupling) instead of hardcoding a version the line has already
|
||||
// passed — a hardcoded "newer" target silently degrades into an
|
||||
// already-current skip when the default agent version moves.
|
||||
func nextAgentTarget(t *testing.T) string {
|
||||
t.Helper()
|
||||
parts, ok := config.ParseDisplayVersion(config.GetAgentDisplayVersion())
|
||||
if !ok {
|
||||
t.Fatalf("agent display version %q does not parse", config.GetAgentDisplayVersion())
|
||||
}
|
||||
return fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2]+1)
|
||||
}
|
||||
|
||||
// Item 20: the agent consumes the master's update intent, downloads the
|
||||
// release asset for its architecture, verifies the sha256 sidecar, swaps
|
||||
// the binary keeping a .bak, and re-execs. Failures must leave the old
|
||||
@@ -22,7 +39,9 @@ import (
|
||||
|
||||
func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
// The agent requests the standalone agent asset (agent/panel
|
||||
// split); the fake release only serves that name.
|
||||
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -78,7 +97,7 @@ func TestAgentUpdateSwapsAndRestarts(t *testing.T) {
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
execCalls := stubExec(t, nil)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 42, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
|
||||
a.runUpdate(updateIntent{RequestedAt: 42, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "new-binary" {
|
||||
@@ -102,7 +121,7 @@ func TestAgentUpdateBadChecksumKeepsOldBinary(t *testing.T) {
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
execCalls := stubExec(t, nil)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 43, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
|
||||
a.runUpdate(updateIntent{RequestedAt: 43, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
@@ -127,8 +146,9 @@ func TestAgentUpdateAlreadyCurrentSkipsDownload(t *testing.T) {
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
stubExec(t, nil)
|
||||
|
||||
// The test binary's display version defaults to A2.0.0.
|
||||
a.runUpdate(updateIntent{RequestedAt: 44, Target: "A2.0.0", Tag: "V2.0.0", BaseURL: srv.URL + "/"})
|
||||
// The intent target equals the test binary's own agent-line
|
||||
// version: already current, whatever the line currently is.
|
||||
a.runUpdate(updateIntent{RequestedAt: 44, Target: config.GetAgentDisplayVersion(), Tag: "V2.0.0", BaseURL: srv.URL + "/"})
|
||||
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("release server hit %d times for an already-current agent", hits.Load())
|
||||
@@ -148,7 +168,7 @@ func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
stubExec(t, errExecBoom)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 45, Target: "A2.0.1", Tag: "V2.0.1", BaseURL: srv.URL + "/"})
|
||||
a.runUpdate(updateIntent{RequestedAt: 45, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
@@ -159,6 +179,94 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ITEM-P3 rider: runUpdate must stop the sing-box core as well as
|
||||
// xray before execSelf replaces the image. Pre-fix only xray was
|
||||
// stopped, leaving the old sing-box orphaned on the inbound ports —
|
||||
// the new image's core then died on bind every poll cycle and the
|
||||
// orphan kept serving a stale config. The fake core is a real
|
||||
// process so "stopped" is observable, and the exec stub records
|
||||
// whether the core was already down at exec time (ordering matters:
|
||||
// stopping after exec would be stopping in the wrong process).
|
||||
func TestAgentUpdateStopsSingboxCoreBeforeExec(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake core is a shell script")
|
||||
}
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, nil)
|
||||
defer srv.Close()
|
||||
a, _ := newUpdateTestAgent(t, srv.URL)
|
||||
|
||||
dir := t.TempDir()
|
||||
corePath := filepath.Join(dir, "sing-box")
|
||||
if err := os.WriteFile(corePath, []byte("#!/bin/sh\nexec sleep 3600\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, "sb.json")
|
||||
if err := os.WriteFile(cfgPath, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.sbProc = &singboxProcess{}
|
||||
if err := a.sbProc.start(corePath, cfgPath, filepath.Join(dir, "sb.log")); err != nil {
|
||||
t.Fatalf("start fake core: %v", err)
|
||||
}
|
||||
if !a.singboxRunning() {
|
||||
t.Fatal("fake sing-box core is not running")
|
||||
}
|
||||
t.Cleanup(func() { a.stopSingbox() })
|
||||
|
||||
var sbRunningAtExec atomic.Bool
|
||||
orig := execSelf
|
||||
execSelf = func(string) error {
|
||||
sbRunningAtExec.Store(a.singboxRunning())
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { execSelf = orig })
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 47, Target: nextAgentTarget(t), Tag: "V2.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
if sbRunningAtExec.Load() {
|
||||
t.Fatal("sing-box core was still running when execSelf fired")
|
||||
}
|
||||
if a.singboxRunning() {
|
||||
t.Fatal("sing-box core is still running after the update")
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep == nil || rep.State != "swapped" {
|
||||
t.Fatalf("report = %+v, want swapped", rep)
|
||||
}
|
||||
}
|
||||
|
||||
var errExecBoom = &execBoomError{}
|
||||
|
||||
type execBoomError struct{}
|
||||
|
||||
@@ -42,6 +42,15 @@ var (
|
||||
// telemetry) read the V/A display version, while internal comparisons
|
||||
// and advertisements keep using GetBaseVersion/GetPanelVersion.
|
||||
displayVersion string
|
||||
// agentDisplayVersion is the AGENT line's own release number,
|
||||
// injected the same way. Since the version-line decoupling it is
|
||||
// deliberately NOT derived from displayVersion: the panel version
|
||||
// (displayVersion) advances with every panel release, while the
|
||||
// agent version advances only when agent code actually changes.
|
||||
// Release builds inject both; a build that injects neither reports
|
||||
// the panel default and the agent-line default below, which are
|
||||
// intentionally different numbers.
|
||||
agentDisplayVersion string
|
||||
)
|
||||
|
||||
// LogLevel represents the logging level for the application.
|
||||
@@ -125,16 +134,56 @@ 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.
|
||||
// defaultAgentDisplayVersion is the agent line's version when a build
|
||||
// does not inject one. It names the last release in which agent code
|
||||
// actually changed (A2.4.0 — the P3 memory-tuning cycle touched agent
|
||||
// code, shipped with panel V2.5.0; before that the line stood at
|
||||
// A2.3.0 while the panel line advanced alone). Bump it ONLY together
|
||||
// with a real agent-code change, in the same commit; panel releases
|
||||
// must never move it.
|
||||
const defaultAgentDisplayVersion = "2.4.0"
|
||||
|
||||
// GetAgentDisplayVersion is the agent's display version ("A2.4.0"),
|
||||
// reported in agent telemetry and shown on agent node rows. It reads
|
||||
// ONLY the agent line's own injected version (falling back to the
|
||||
// agent-line default): changing the panel's displayVersion injection
|
||||
// must never move it — that independence is the decoupling contract.
|
||||
func GetAgentDisplayVersion() string {
|
||||
return "A" + GetDisplayVersion()
|
||||
return "A" + bareAgentDisplayVersion()
|
||||
}
|
||||
|
||||
// bareAgentDisplayVersion is the agent line's bare numeric version,
|
||||
// normalized like bareDisplayVersion (a single leading display letter
|
||||
// is stripped, so builds may inject "2.3.0" or "A2.3.0").
|
||||
func bareAgentDisplayVersion() string {
|
||||
v := strings.TrimSpace(agentDisplayVersion)
|
||||
if v == "" {
|
||||
v = defaultAgentDisplayVersion
|
||||
}
|
||||
if len(v) > 1 && (v[0] == 'A' || v[0] == 'a' || v[0] == 'V' || v[0] == 'v') {
|
||||
return v[1:]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ParseDisplayVersion parses a display version into its three numeric
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// Version-line decoupling: the agent's display version comes ONLY from
|
||||
// the agent line's own injection (agentDisplayVersion), never from the
|
||||
// panel's displayVersion. These tests lock that independence: moving
|
||||
// either injection moves exactly one line's self-report.
|
||||
func TestAgentDisplayVersionIndependentSource(t *testing.T) {
|
||||
origPanel, origAgent := displayVersion, agentDisplayVersion
|
||||
t.Cleanup(func() { displayVersion, agentDisplayVersion = origPanel, origAgent })
|
||||
|
||||
// Agent injection in bare and A-forms both normalize to the A-form.
|
||||
for _, injected := range []string{"2.4.0", "A2.4.0", "a2.4.0"} {
|
||||
agentDisplayVersion = injected
|
||||
if got := GetAgentDisplayVersion(); got != "A2.4.0" {
|
||||
t.Fatalf("agentDisplayVersion=%q: GetAgentDisplayVersion = %q, want A2.4.0", injected, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Sweeping the panel injection across values must leave the agent
|
||||
// self-report bit-identical (the phantom-update regression shape:
|
||||
// a panel-only release used to drag every agent's version along).
|
||||
agentDisplayVersion = "2.3.0"
|
||||
for _, panel := range []string{"", "2.0.0", "2.4.0", "V2.4.0", "9.9.9"} {
|
||||
displayVersion = panel
|
||||
if got := GetAgentDisplayVersion(); got != "A2.3.0" {
|
||||
t.Fatalf("displayVersion=%q moved the agent version: got %q, want A2.3.0", panel, got)
|
||||
}
|
||||
if got := GetMasterDisplayVersion(); panel != "" && got == "V2.3.0" {
|
||||
t.Fatalf("displayVersion=%q: master version collided with the agent line: %q", panel, got)
|
||||
}
|
||||
}
|
||||
|
||||
// And the reverse: moving the agent injection must not move the
|
||||
// master's self-report.
|
||||
displayVersion = "2.4.0"
|
||||
agentDisplayVersion = "2.5.0"
|
||||
if got := GetMasterDisplayVersion(); got != "V2.4.0" {
|
||||
t.Fatalf("agent injection moved the master version: got %q, want V2.4.0", got)
|
||||
}
|
||||
if got := GetAgentDisplayVersion(); got != "A2.5.0" {
|
||||
t.Fatalf("agent self-report = %q, want A2.5.0", got)
|
||||
}
|
||||
}
|
||||
@@ -55,25 +55,41 @@ func TestGetBaseVersionBuildVersionOverride(t *testing.T) {
|
||||
|
||||
func TestDisplayVersionDefaultsAndOverride(t *testing.T) {
|
||||
orig := displayVersion
|
||||
t.Cleanup(func() { displayVersion = orig })
|
||||
origAgent := agentDisplayVersion
|
||||
t.Cleanup(func() { displayVersion = orig; agentDisplayVersion = origAgent })
|
||||
|
||||
displayVersion = ""
|
||||
agentDisplayVersion = ""
|
||||
if got := GetDisplayVersion(); got != "2.0.0" {
|
||||
t.Fatalf("default display version = %q, want 2.0.0", got)
|
||||
}
|
||||
if got := GetMasterDisplayVersion(); got != "V2.0.0" {
|
||||
t.Fatalf("default master display version = %q, want V2.0.0", got)
|
||||
}
|
||||
if got := GetAgentDisplayVersion(); got != "A2.0.0" {
|
||||
t.Fatalf("default agent display version = %q, want A2.0.0", got)
|
||||
// The two lines have independent defaults since the decoupling:
|
||||
// the panel default (2.0.0) and the agent line's own version
|
||||
// (2.4.0, its last real change) deliberately differ.
|
||||
if got := GetAgentDisplayVersion(); got != "A2.4.0" {
|
||||
t.Fatalf("default agent display version = %q, want A2.4.0", got)
|
||||
}
|
||||
|
||||
displayVersion = "1.0.1"
|
||||
if got := GetMasterDisplayVersion(); got != "V1.0.1" {
|
||||
t.Fatalf("injected master display version = %q, want V1.0.1", got)
|
||||
}
|
||||
if got := GetAgentDisplayVersion(); got != "A1.0.1" {
|
||||
t.Fatalf("injected agent display version = %q, want A1.0.1", got)
|
||||
// Panel injection must NOT move the agent line (decoupling lock).
|
||||
if got := GetAgentDisplayVersion(); got != "A2.4.0" {
|
||||
t.Fatalf("agent display version after panel injection = %q, want A2.4.0 (unchanged)", got)
|
||||
}
|
||||
|
||||
// A release build may inject the number in the master's V-form:
|
||||
// the master getter must not double its letter ("VV1.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 != "A2.4.0" {
|
||||
t.Fatalf("agent display version after V-form panel injection = %q, want A2.4.0 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,33 +36,3 @@ func GreatestExpr(a, b string) string {
|
||||
}
|
||||
return fmt.Sprintf("MAX(%s, %s)", a, b)
|
||||
}
|
||||
|
||||
// ClientTrafficEnableMergeExpr: placeholders nodeEnable, nodeExpiry, nodeTotal,
|
||||
// now, deltaUp, deltaDown. Mirrors nodeDisableIsStale (#6228 / #4917).
|
||||
func ClientTrafficEnableMergeExpr() string {
|
||||
if IsPostgres() {
|
||||
return `CASE
|
||||
WHEN ?::boolean THEN enable::boolean
|
||||
WHEN (expiry_time <> CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT))
|
||||
AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
|
||||
AND (total <= 0 OR up + ? + down + ? < total) THEN enable::boolean
|
||||
ELSE false
|
||||
END`
|
||||
}
|
||||
return `CASE
|
||||
WHEN ? THEN enable
|
||||
WHEN (expiry_time <> CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT))
|
||||
AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT))
|
||||
AND (total <= 0 OR up + ? + down + ? < total) THEN enable
|
||||
ELSE 0
|
||||
END`
|
||||
}
|
||||
|
||||
// ClientTrafficExpiryMergeExpr: placeholder nodeExpiry once. Master absolute is
|
||||
// kept; CAST avoids Postgres int4 inference on ms timestamps.
|
||||
func ClientTrafficExpiryMergeExpr() string {
|
||||
return `CASE
|
||||
WHEN expiry_time > 0 THEN expiry_time
|
||||
ELSE CAST(? AS BIGINT)
|
||||
END`
|
||||
}
|
||||
|
||||
@@ -108,6 +108,13 @@ type AgentNode struct {
|
||||
SeenIPv6 string `json:"seenIpv6"`
|
||||
SeenIPAt int64 `json:"seenIpAt"`
|
||||
|
||||
// ProbeOnly marks the node as probe-only (V2.1.0): the master serves
|
||||
// no core config for it and the agent runs no core at all — only the
|
||||
// probe channels (heartbeat, fast report, latency tasks) stay live.
|
||||
// Set from the panel's node form; the agent can also preset it in
|
||||
// its local agent.json, either source turns the mode on.
|
||||
ProbeOnly bool `json:"probeOnly"`
|
||||
|
||||
// One-click update (item 20): the master records an update intent here;
|
||||
// the agent picks it up from its config poll, downloads + verifies +
|
||||
// swaps its binary, restarts itself and reports the outcome back on
|
||||
|
||||
@@ -183,13 +183,12 @@ type HistoryOfSeeders struct {
|
||||
const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000
|
||||
|
||||
const (
|
||||
ApiScopeAdmin = "admin"
|
||||
ApiScopeMonitor = "monitor"
|
||||
ApiScopeNodeSync = "node-sync"
|
||||
ApiScopeAdmin = "admin"
|
||||
ApiScopeMonitor = "monitor"
|
||||
)
|
||||
|
||||
func IsKnownApiScope(s string) bool {
|
||||
return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync
|
||||
return s == ApiScopeAdmin || s == ApiScopeMonitor
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The agent-version.txt marker is the master's source of truth for the
|
||||
// agent line since the version decoupling: a panel release whose agent
|
||||
// assets are unchanged reuses carries a marker BELOW its own tag
|
||||
// number, and that marker — never the tag — decides agent update
|
||||
// availability.
|
||||
|
||||
func TestParseAgentVersionMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
body string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"A2.3.0\n", "A2.3.0", true},
|
||||
{"2.4.0", "A2.4.0", true},
|
||||
{" A2.10.3 \n", "A2.10.3", true},
|
||||
{"\n\nA2.3.0\nextra lines ignored\n", "A2.3.0", true},
|
||||
{"", "", false},
|
||||
{"\n\n", "", false},
|
||||
{"dev-latest", "", false},
|
||||
{"A2.3", "", false},
|
||||
{"A2.3.0.1", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseAgentVersionMarker([]byte(c.body))
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Fatalf("ParseAgentVersionMarker(%q) = %q, %v; want %q, %v", c.body, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markerServer serves /<tag>/agent-version.txt from the given map;
|
||||
// unknown tags 404 like a pre-decoupling release.
|
||||
func markerServer(t *testing.T, markers map[string]string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for tag, body := range markers {
|
||||
if r.URL.Path == "/download/"+tag+"/"+AgentVersionMarkerAsset {
|
||||
_, _ = w.Write([]byte(body))
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv(BaseURLEnv, srv.URL+"/download/")
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestResolveLatestAgentTargetPrefersMarker(t *testing.T) {
|
||||
markerServer(t, map[string]string{"V2.5.0": "A2.3.0\n"})
|
||||
got, err := ResolveLatestAgentTarget(context.Background(), nil, "V2.5.0")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveLatestAgentTarget: %v", err)
|
||||
}
|
||||
// The whole point of the decoupling: panel tag V2.5.0, agent line
|
||||
// still A2.3.0 — the answer must be the marker, not A2.5.0.
|
||||
if got != "A2.3.0" {
|
||||
t.Fatalf("target = %q, want A2.3.0 from the marker", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLatestAgentTargetFallsBackToTag(t *testing.T) {
|
||||
markerServer(t, map[string]string{}) // no markers anywhere: pre-decoupling world
|
||||
got, err := ResolveLatestAgentTarget(context.Background(), nil, "V2.4.0")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveLatestAgentTarget: %v", err)
|
||||
}
|
||||
if got != "A2.4.0" {
|
||||
t.Fatalf("target = %q, want A2.4.0 derived from the tag", got)
|
||||
}
|
||||
if _, err := ResolveLatestAgentTarget(context.Background(), nil, "dev-latest"); err == nil {
|
||||
t.Fatal("unparseable tag without a marker must be an error")
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,34 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the release download base of the fork's own Gitea.
|
||||
// A release tag directory hangs directly below it:
|
||||
// <base><tag>/x-ui-linux-<arch> plus a "<asset>.sha256" sidecar.
|
||||
// A release tag directory hangs directly below it, holding the panel
|
||||
// assets (<base><tag>/x-ui-linux-<arch>) and — since the agent/panel
|
||||
// split — the standalone agent assets (x-ui-agent-linux-<arch>),
|
||||
// each with a "<asset>.sha256" sidecar.
|
||||
const DefaultBaseURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/"
|
||||
|
||||
// LatestReleaseAPI is the Gitea API endpoint for the newest release of the
|
||||
// fork's repository (the same source the panel update check reads).
|
||||
const LatestReleaseAPI = "https://git.881218.xyz/api/v1/repos/rogue/3x-ui-agent/releases/latest"
|
||||
|
||||
// AgentVersionMarkerAsset is the release asset that carries the AGENT
|
||||
// line's current version ("A2.3.0") for a release. Since the agent /
|
||||
// panel version-line decoupling, a panel release's tag no longer
|
||||
// implies the agent version: the agent assets inside the release may
|
||||
// be byte-identical reuses of an older agent build. The marker is a
|
||||
// tiny text asset (single line, A-form) written by the release flow
|
||||
// (see docs/release.md); masters read it to learn the agent line's
|
||||
// latest version independently of the panel tag. Releases published
|
||||
// before the decoupling (<= V2.4.0) carry no marker — readers fall
|
||||
// back to deriving the A-form from the tag, which is exact for those
|
||||
// releases because their agent builds were stamped from the tag.
|
||||
const AgentVersionMarkerAsset = "agent-version.txt"
|
||||
|
||||
// BaseURLEnv overrides the release base (mirrors, tests). Read at call
|
||||
// time so a process never caches a stale override.
|
||||
const BaseURLEnv = "XUI_RELEASE_BASE_URL"
|
||||
@@ -41,7 +58,9 @@ func BaseURL() string {
|
||||
return DefaultBaseURL
|
||||
}
|
||||
|
||||
// AssetName maps a GOARCH to the release asset name published for it.
|
||||
// AssetName maps a GOARCH to the release asset name published for it:
|
||||
// the panel (dual-mode) binary. The panel's own native self-update
|
||||
// downloads this asset; pre-split agents consumed it too.
|
||||
func AssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
@@ -53,6 +72,24 @@ func AssetName(goarch string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAssetName maps a GOARCH to the standalone agent asset published
|
||||
// since the agent/panel split (phase 1). A split agent's one-click
|
||||
// update downloads this asset — never the panel binary — so a node
|
||||
// keeps its slim form across updates. Releases predating the split do
|
||||
// not carry these assets; an intent pointing at such a tag fails the
|
||||
// download and the agent keeps running its old binary (the designed
|
||||
// failure mode of runUpdate).
|
||||
func AgentAssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "x-ui-agent-linux-amd64", nil
|
||||
case "arm64":
|
||||
return "x-ui-agent-linux-arm64", nil
|
||||
default:
|
||||
return "", fmt.Errorf("no agent release asset for architecture %q", goarch)
|
||||
}
|
||||
}
|
||||
|
||||
// LatestReleaseURLEnv overrides the release-lookup endpoint as a whole
|
||||
// (mirrors, tests), analogous to BaseURLEnv.
|
||||
const LatestReleaseURLEnv = "XUI_RELEASE_LATEST_URL"
|
||||
@@ -95,6 +132,74 @@ func FetchLatestReleaseTag(ctx context.Context, client *http.Client) (string, er
|
||||
return strings.TrimSpace(body.TagName), nil
|
||||
}
|
||||
|
||||
// ParseAgentVersionMarker parses the agent-version.txt marker body:
|
||||
// the first non-empty line must be a display version (bare or
|
||||
// letter-prefixed); the result is normalized to the A-form.
|
||||
func ParseAgentVersionMarker(data []byte) (string, bool) {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts, ok := config.ParseDisplayVersion(line)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2]), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// FetchAgentVersionMarker downloads and parses the agent version
|
||||
// marker of one release tag. Any failure (missing asset on a
|
||||
// pre-decoupling release, network, malformed body) is an error so
|
||||
// callers can fall back deliberately.
|
||||
func FetchAgentVersionMarker(ctx context.Context, client *http.Client, tag string) (string, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
url := BaseURL() + strings.TrimSpace(tag) + "/" + AgentVersionMarkerAsset
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("agent version marker: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, ok := ParseAgentVersionMarker(data)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("agent version marker: malformed body %q", strings.TrimSpace(string(data)))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ResolveLatestAgentTarget returns the agent line's latest version
|
||||
// (A-form) as published by the release at tag: the release's
|
||||
// agent-version.txt marker when present, else the legacy derivation
|
||||
// from the (panel) tag itself. The derivation is kept for
|
||||
// pre-decoupling releases, whose agent builds were stamped from the
|
||||
// tag, so it stays exact there; it must never be used to override a
|
||||
// marker that IS present.
|
||||
func ResolveLatestAgentTarget(ctx context.Context, client *http.Client, tag string) (string, error) {
|
||||
if v, err := FetchAgentVersionMarker(ctx, client, tag); err == nil {
|
||||
return v, nil
|
||||
}
|
||||
parts, ok := config.ParseDisplayVersion(tag)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cannot resolve agent target: no marker in release %q and tag is not a display version", tag)
|
||||
}
|
||||
return fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2]), nil
|
||||
}
|
||||
|
||||
// ParseSHA256Sidecar extracts the digest from a sha256sum-style sidecar
|
||||
// file ("<hex> <name>" or a bare "<hex>", possibly with trailing lines).
|
||||
func ParseSHA256Sidecar(data []byte) (string, bool) {
|
||||
|
||||
@@ -47,6 +47,18 @@ func TestAssetName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAssetName(t *testing.T) {
|
||||
if name, err := AgentAssetName("amd64"); err != nil || name != "x-ui-agent-linux-amd64" {
|
||||
t.Fatalf("amd64: %q, %v", name, err)
|
||||
}
|
||||
if name, err := AgentAssetName("arm64"); err != nil || name != "x-ui-agent-linux-arm64" {
|
||||
t.Fatalf("arm64: %q, %v", name, err)
|
||||
}
|
||||
if _, err := AgentAssetName("riscv64"); err == nil {
|
||||
t.Fatal("unknown arch must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapExecutableKeepsBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "xui-agent")
|
||||
|
||||
+35
-3
@@ -957,7 +957,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
case "tls":
|
||||
applyShareTLSParams(stream, params)
|
||||
case "reality":
|
||||
applyShareRealityParams(stream, params)
|
||||
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
|
||||
default:
|
||||
params["security"] = "none"
|
||||
}
|
||||
@@ -1010,7 +1010,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
case "tls":
|
||||
applyShareTLSParams(stream, params)
|
||||
case "reality":
|
||||
applyShareRealityParams(stream, params)
|
||||
applyShareRealityParams(stream, singboxFragmentServerName(inbound.SingboxConfig), params)
|
||||
if streamNetwork == "tcp" && len(client.Flow) > 0 && !inbound.DisableFlow {
|
||||
params["flow"] = client.Flow
|
||||
}
|
||||
@@ -1700,7 +1700,30 @@ func hysteriaPinHex(pin string) string {
|
||||
return pin
|
||||
}
|
||||
|
||||
func applyShareRealityParams(stream map[string]any, params map[string]string) {
|
||||
// singboxFragmentServerName returns the tls.server_name of the
|
||||
// inbound's stored sing-box fragment — the ONE name the sing-box
|
||||
// REALITY server actually serves. The converter puts exactly one
|
||||
// name there (realitySettings.serverNames[0], see internal/singbox)
|
||||
// while the legacy settings may list dozens. "" when the inbound has
|
||||
// no fragment, the fragment is unparseable, or it carries no server
|
||||
// name; callers then keep the historical behaviour.
|
||||
func singboxFragmentServerName(singboxConfig string) string {
|
||||
if strings.TrimSpace(singboxConfig) == "" {
|
||||
return ""
|
||||
}
|
||||
var fragment map[string]any
|
||||
if err := json.Unmarshal([]byte(singboxConfig), &fragment); err != nil {
|
||||
return ""
|
||||
}
|
||||
if tlsBlock, ok := fragment["tls"].(map[string]any); ok {
|
||||
if name, ok := tlsBlock["server_name"].(string); ok {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyShareRealityParams(stream map[string]any, fragmentSNI string, params map[string]string) {
|
||||
params["security"] = "reality"
|
||||
realitySetting, _ := stream["realitySettings"].(map[string]any)
|
||||
realitySettings, _ := searchKey(realitySetting, "settings")
|
||||
@@ -1710,6 +1733,15 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
|
||||
params["sni"], _ = sNames[random.Num(len(sNames))].(string)
|
||||
}
|
||||
}
|
||||
// The fragment's server_name wins over the pick above: xray
|
||||
// serves the whole serverNames list, so a random entry was
|
||||
// safe there, but the sing-box core serves only the
|
||||
// fragment's single name — any other entry is refused as an
|
||||
// invalid REALITY connection (the PPF-L3 outage: 29 listed
|
||||
// names, core serving only the first).
|
||||
if fragmentSNI != "" {
|
||||
params["sni"] = fragmentSNI
|
||||
}
|
||||
if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok {
|
||||
params["pbk"], _ = pbkValue.(string)
|
||||
}
|
||||
|
||||
@@ -89,6 +89,60 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Scope D: with several serverNames, the generated link's sni must be
|
||||
// the sing-box fragment's tls.server_name — the only name the sing-box
|
||||
// REALITY server serves (the converter stores serverNames[0] there).
|
||||
// The historical behaviour picked a uniformly random list entry per
|
||||
// generation, which xray tolerated (it serves the whole list) but
|
||||
// sing-box refuses as an invalid connection. Without a fragment the
|
||||
// sni still comes from the list.
|
||||
func TestGenVlessLink_RealitySniPrefersSingboxFragment(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"reality",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"realitySettings":{
|
||||
"serverNames":["amd.example.com","connect-sit.amd.example.com","www.amd.example.com"],
|
||||
"shortIds":["ab12cd"],
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
|
||||
}
|
||||
}`
|
||||
s := &SubService{}
|
||||
inbound := shareLinkInbound(stream)
|
||||
inbound.SingboxConfig = `{"type":"vless","tag":"in","listen":"::","listen_port":443,` +
|
||||
`"tls":{"enabled":true,"server_name":"amd.example.com",` +
|
||||
`"reality":{"enabled":true,"short_id":["ab12cd"]}}}`
|
||||
|
||||
// The losing branch is a random pick: sample enough generations
|
||||
// that a regression to it cannot survive by luck (2/3 wrong per
|
||||
// draw; 30 draws make a false pass ~2e-6).
|
||||
for i := 0; i < 30; i++ {
|
||||
link := s.genVlessLink(inbound, "user")
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
if got := u.Query().Get("sni"); got != "amd.example.com" {
|
||||
t.Fatalf("draw %d: sni = %q, want the fragment's amd.example.com\nlink: %s", i, got, link)
|
||||
}
|
||||
}
|
||||
|
||||
// No fragment (or an unusable one): historical behaviour — the
|
||||
// sni is some entry of the serverNames list.
|
||||
for _, frag := range []string{"", "{not json", `{"type":"vless"}`} {
|
||||
inbound.SingboxConfig = frag
|
||||
link := s.genVlessLink(inbound, "user")
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link %q: %v", link, err)
|
||||
}
|
||||
switch got := u.Query().Get("sni"); got {
|
||||
case "amd.example.com", "connect-sit.amd.example.com", "www.amd.example.com":
|
||||
default:
|
||||
t.Fatalf("fragment %q: sni = %q, want a serverNames entry", frag, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
|
||||
// with distinct subIds.
|
||||
func realityTwoClientInbound() *model.Inbound {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package sys
|
||||
package memtuning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,12 +6,24 @@ import (
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
memLimitHeadroomPercent = 90
|
||||
defaultGCPercent = 75
|
||||
defaultReleaseMinutes = 10
|
||||
|
||||
// Agent tuning profile (ITEM-P3 A1). The baseline measurement put
|
||||
// the agent's live heap at ~7-8 MiB of anonymous memory, yet
|
||||
// production agents idled at 38-40 MB RSS: with Go defaults
|
||||
// (GOGC=100, no soft limit, no periodic release) the runtime
|
||||
// holds high-water arenas indefinitely. The agent profile pairs
|
||||
// the panel's GOGC env value (50) with an explicit 24 MiB soft
|
||||
// budget — roughly 3x the live heap, enough headroom for stats
|
||||
// bursts, far below the untuned high-water mark.
|
||||
agentGCPercent = 50
|
||||
agentSoftLimitMB = 24
|
||||
)
|
||||
|
||||
// ApplyMemoryTuning configures the Go runtime for a lower, steadier footprint and
|
||||
@@ -32,11 +44,15 @@ func ApplyMemoryTuning() []string {
|
||||
// applyGCPercent lowers GOGC so the heap high-water mark, and thus RSS, stays
|
||||
// smaller. An explicit GOGC env (including GOGC=off) is left to the runtime.
|
||||
func applyGCPercent() string {
|
||||
return applyGCPercentWithDefault(defaultGCPercent)
|
||||
}
|
||||
|
||||
func applyGCPercentWithDefault(def int) string {
|
||||
if _, ok := os.LookupEnv("GOGC"); ok {
|
||||
return "GC percent: GOGC env (handled by the Go runtime)"
|
||||
}
|
||||
|
||||
pct := defaultGCPercent
|
||||
pct := def
|
||||
if v := strings.TrimSpace(os.Getenv("XUI_GOGC")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
pct = n
|
||||
@@ -50,6 +66,65 @@ func applyGCPercent() string {
|
||||
return fmt.Sprintf("GC percent set to %d", pct)
|
||||
}
|
||||
|
||||
// ApplyAgentMemoryTuning is ApplyMemoryTuning with the node agent's
|
||||
// budget profile (ITEM-P3 A1), applied by both agent entries (the
|
||||
// standalone cmd/xui-agent and the dual-mode binary's agent branch).
|
||||
// It differs from the panel profile in two ways: the default GC
|
||||
// percent is 50 instead of 75, and the soft limit falls back to an
|
||||
// explicit agent budget (agentSoftLimitMB) instead of "no budget".
|
||||
// The cgroup fallback is deliberately not used for the agent: fleet
|
||||
// agents run on whole VMs or containers whose cgroup limit describes
|
||||
// the box, not an agent budget, and would defeat the tighter default.
|
||||
// Explicit env budgets (GOMEMLIMIT, XUI_MEMORY_LIMIT) still win.
|
||||
func ApplyAgentMemoryTuning() []string {
|
||||
lines := []string{applyGCPercentWithDefault(agentGCPercent)}
|
||||
if limit, source := applyAgentMemoryLimit(); limit > 0 {
|
||||
lines = append(lines, fmt.Sprintf("Go memory soft limit set to %d MiB (%s)", limit>>20, source))
|
||||
} else {
|
||||
lines = append(lines, "Go memory soft limit not enforced: "+source)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func applyAgentMemoryLimit() (int64, string) {
|
||||
if strings.TrimSpace(os.Getenv("GOMEMLIMIT")) != "" {
|
||||
return 0, "GOMEMLIMIT env (handled by the Go runtime)"
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(os.Getenv("XUI_MEMORY_LIMIT")); v != "" {
|
||||
if mb, err := strconv.ParseInt(v, 10, 64); err == nil && mb > 0 {
|
||||
limit := mb << 20
|
||||
debug.SetMemoryLimit(limit)
|
||||
return limit, "XUI_MEMORY_LIMIT=" + v + "MiB"
|
||||
}
|
||||
}
|
||||
|
||||
limit := int64(agentSoftLimitMB) << 20
|
||||
debug.SetMemoryLimit(limit)
|
||||
return limit, "agent default budget"
|
||||
}
|
||||
|
||||
// StartPeriodicMemoryRelease launches the goroutine that returns
|
||||
// freed heap pages to the OS on the shared release cadence
|
||||
// (MemoryReleaseIntervalMinutes; 0 disables, and no goroutine is
|
||||
// started). The panel runs the same cadence through its job
|
||||
// scheduler; agent processes have no scheduler, so their entries
|
||||
// start this loop directly. It never stops — the loop's lifetime is
|
||||
// the process's.
|
||||
func StartPeriodicMemoryRelease() {
|
||||
mins := MemoryReleaseIntervalMinutes()
|
||||
if mins <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Duration(mins) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// applyMemoryLimit sets the soft limit only from an explicit budget: GOMEMLIMIT
|
||||
// env (left to the runtime), XUI_MEMORY_LIMIT in MiB, or a real cgroup limit at
|
||||
// 90% to leave headroom for non-heap and the xray child. No budget -> Go default.
|
||||
@@ -0,0 +1,105 @@
|
||||
package memtuning
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lookupGOGC reports whether a GOGC env is present at all: presence
|
||||
// (even empty handling aside) makes the tuning defer to the runtime,
|
||||
// which the default-profile tests cannot exercise.
|
||||
func lookupGOGC() (string, bool) {
|
||||
return os.LookupEnv("GOGC")
|
||||
}
|
||||
|
||||
// The agent tuning profile (ITEM-P3 A1): GOGC default 50 and a
|
||||
// 24 MiB default soft budget, with explicit env budgets still
|
||||
// winning. The runtime knobs are process-global, so each test
|
||||
// restores whatever GC percent / memory limit it found.
|
||||
|
||||
func preserveRuntimeKnobs(t *testing.T) {
|
||||
t.Helper()
|
||||
prevGC := debug.SetGCPercent(-1)
|
||||
prevLimit := debug.SetMemoryLimit(-1)
|
||||
t.Cleanup(func() {
|
||||
debug.SetGCPercent(prevGC)
|
||||
debug.SetMemoryLimit(prevLimit)
|
||||
})
|
||||
}
|
||||
|
||||
func unsetenv(t *testing.T, keys ...string) {
|
||||
t.Helper()
|
||||
for _, k := range keys {
|
||||
t.Setenv(k, "")
|
||||
// t.Setenv cannot unset; an empty value is what the tuning
|
||||
// code treats as absent (it trims before checking), except
|
||||
// for GOGC where LookupEnv counts presence — see the GOGC
|
||||
// case below, which is exercised only via XUI_GOGC instead.
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentMemoryTuningDefaults(t *testing.T) {
|
||||
preserveRuntimeKnobs(t)
|
||||
unsetenv(t, "GOMEMLIMIT", "XUI_MEMORY_LIMIT", "XUI_GOGC")
|
||||
if _, present := lookupGOGC(); present {
|
||||
t.Skip("GOGC is set in the test environment")
|
||||
}
|
||||
|
||||
lines := ApplyAgentMemoryTuning()
|
||||
joined := strings.Join(lines, "\n")
|
||||
if !strings.Contains(joined, "GC percent set to 50") {
|
||||
t.Fatalf("lines = %v, want GC percent 50", lines)
|
||||
}
|
||||
if !strings.Contains(joined, "Go memory soft limit set to 24 MiB (agent default budget)") {
|
||||
t.Fatalf("lines = %v, want 24 MiB agent default budget", lines)
|
||||
}
|
||||
if got := debug.SetMemoryLimit(-1); got != int64(24)<<20 {
|
||||
t.Fatalf("memory limit = %d, want %d", got, int64(24)<<20)
|
||||
}
|
||||
if got := debug.SetGCPercent(-1); got != 50 {
|
||||
t.Fatalf("GC percent = %d, want 50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentMemoryTuningExplicitLimitWins(t *testing.T) {
|
||||
preserveRuntimeKnobs(t)
|
||||
unsetenv(t, "GOMEMLIMIT")
|
||||
t.Setenv("XUI_MEMORY_LIMIT", "40")
|
||||
|
||||
lines := ApplyAgentMemoryTuning()
|
||||
if !strings.Contains(strings.Join(lines, "\n"), "Go memory soft limit set to 40 MiB (XUI_MEMORY_LIMIT=40MiB)") {
|
||||
t.Fatalf("lines = %v, want XUI_MEMORY_LIMIT budget", lines)
|
||||
}
|
||||
if got := debug.SetMemoryLimit(-1); got != int64(40)<<20 {
|
||||
t.Fatalf("memory limit = %d, want %d", got, int64(40)<<20)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentMemoryTuningGOMEMLIMITEnvWins(t *testing.T) {
|
||||
preserveRuntimeKnobs(t)
|
||||
before := debug.SetMemoryLimit(-1)
|
||||
t.Setenv("GOMEMLIMIT", "32MiB")
|
||||
|
||||
lines := ApplyAgentMemoryTuning()
|
||||
if !strings.Contains(strings.Join(lines, "\n"), "Go memory soft limit not enforced: GOMEMLIMIT env") {
|
||||
t.Fatalf("lines = %v, want GOMEMLIMIT left to the runtime", lines)
|
||||
}
|
||||
if got := debug.SetMemoryLimit(-1); got != before {
|
||||
t.Fatalf("memory limit changed to %d, want untouched %d", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentMemoryTuningXUIGOGCOverride(t *testing.T) {
|
||||
preserveRuntimeKnobs(t)
|
||||
if _, present := lookupGOGC(); present {
|
||||
t.Skip("GOGC is set in the test environment")
|
||||
}
|
||||
t.Setenv("XUI_GOGC", "80")
|
||||
|
||||
lines := ApplyAgentMemoryTuning()
|
||||
if !strings.Contains(strings.Join(lines, "\n"), "GC percent set to 80") {
|
||||
t.Fatalf("lines = %v, want XUI_GOGC override", lines)
|
||||
}
|
||||
}
|
||||
+198
-108
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -146,46 +147,91 @@ func (a *AgentController) captureSeenIP(c *gin.Context, agent *model.AgentNode)
|
||||
if ip == nil || agent == nil {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{"seen_ip_at": time.Now().UnixMilli()}
|
||||
now := time.Now().UnixMilli()
|
||||
cols := []agentColumn{{"seen_ip_at", now}, {"updated_at", now}}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if agent.SeenIPv4 == v4.String() {
|
||||
return
|
||||
}
|
||||
updates["seen_ipv4"] = v4.String()
|
||||
cols = append(cols, agentColumn{"seen_ipv4", v4.String()})
|
||||
} else {
|
||||
if agent.SeenIPv6 == ip.String() {
|
||||
return
|
||||
}
|
||||
updates["seen_ipv6"] = ip.String()
|
||||
cols = append(cols, agentColumn{"seen_ipv6", ip.String()})
|
||||
}
|
||||
if err := database.GetDB().Model(&model.AgentNode{}).
|
||||
Where("id = ?", agent.Id).Updates(updates).Error; err != nil {
|
||||
if err := execAgentColumns(uint(agent.Id), cols); err != nil {
|
||||
return
|
||||
}
|
||||
if v, ok := updates["seen_ipv4"]; ok {
|
||||
agent.SeenIPv4 = v.(string)
|
||||
for _, c := range cols {
|
||||
switch c.name {
|
||||
case "seen_ipv4":
|
||||
agent.SeenIPv4 = c.value.(string)
|
||||
case "seen_ipv6":
|
||||
agent.SeenIPv6 = c.value.(string)
|
||||
}
|
||||
}
|
||||
if v, ok := updates["seen_ipv6"]; ok {
|
||||
agent.SeenIPv6 = v.(string)
|
||||
agent.SeenIPAt = now
|
||||
}
|
||||
|
||||
// agentColumn is one column assignment for execAgentColumns.
|
||||
type agentColumn struct {
|
||||
name string
|
||||
value any
|
||||
}
|
||||
|
||||
// execAgentColumns writes exactly the given columns of one
|
||||
// agent_nodes row with a single raw UPDATE (ITEM-P3 M4). The ingest
|
||||
// hot paths — heartbeats, traffic reports, probe fast-reports, for
|
||||
// every agent every few seconds — previously paid gorm's statement
|
||||
// build/clone/scan machinery per call; the baseline measured that
|
||||
// family at roughly a third of the master's allocation churn. The
|
||||
// column sets and values written are unchanged from the gorm calls
|
||||
// this replaces, including updated_at, which gorm's autoUpdateTime
|
||||
// stamped on every one of them.
|
||||
func execAgentColumns(id uint, cols []agentColumn) error {
|
||||
if len(cols) == 0 {
|
||||
return nil
|
||||
}
|
||||
agent.SeenIPAt = updates["seen_ip_at"].(int64)
|
||||
sqlDB, err := database.GetDB().DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("UPDATE agent_nodes SET ")
|
||||
args := make([]any, 0, len(cols)+1)
|
||||
for i, c := range cols {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(c.name)
|
||||
sb.WriteString(" = ?")
|
||||
args = append(args, c.value)
|
||||
}
|
||||
sb.WriteString(" WHERE id = ?")
|
||||
args = append(args, id)
|
||||
_, err = sqlDB.Exec(sb.String(), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *AgentController) touchAgent(id uint, name string) {
|
||||
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
|
||||
now := time.Now().UnixMilli()
|
||||
cols := []agentColumn{{"last_seen", now}, {"updated_at", now}}
|
||||
if name != "" {
|
||||
updates["name"] = name
|
||||
cols = append(cols, agentColumn{"name", name})
|
||||
}
|
||||
db := database.GetDB()
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
_ = execAgentColumns(id, cols)
|
||||
// If bound to a node, refresh the node's heartbeat so the legacy node
|
||||
// list shows it online (the legacy probe can't reach agent-only nodes).
|
||||
var nodeID *int
|
||||
_ = db.Model(&model.AgentNode{}).Select("node_id").Where("id = ?", id).Scan(&nodeID).Error
|
||||
if nodeID != nil {
|
||||
now := time.Now().UnixMilli()
|
||||
_ = db.Exec("UPDATE nodes SET last_heartbeat = ?, status = 'online' WHERE id = ?", now, *nodeID).Error
|
||||
sqlDB, err := database.GetDB().DB()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var nodeID sql.NullInt64
|
||||
if err := sqlDB.QueryRow("SELECT node_id FROM agent_nodes WHERE id = ?", id).Scan(&nodeID); err != nil || !nodeID.Valid {
|
||||
return
|
||||
}
|
||||
_ = database.GetDB().Exec("UPDATE nodes SET last_heartbeat = ?, status = 'online' WHERE id = ?", now, nodeID.Int64).Error
|
||||
}
|
||||
|
||||
// register activates a pre-issued token. Tokens are issued bound to a node via
|
||||
@@ -233,35 +279,36 @@ type heartbeatTelemetry struct {
|
||||
// updateAgentTelemetry persists the reported telemetry fields onto the
|
||||
// agent row. Only fields actually present in the report are written.
|
||||
func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) {
|
||||
updates := map[string]any{"telemetry_at": time.Now().UnixMilli()}
|
||||
now := time.Now().UnixMilli()
|
||||
cols := []agentColumn{{"telemetry_at", now}, {"updated_at", now}}
|
||||
if t.AgentVersion != nil {
|
||||
updates["agent_version"] = *t.AgentVersion
|
||||
cols = append(cols, agentColumn{"agent_version", *t.AgentVersion})
|
||||
}
|
||||
if t.XrayVersion != nil {
|
||||
updates["xray_version"] = *t.XrayVersion
|
||||
cols = append(cols, agentColumn{"xray_version", *t.XrayVersion})
|
||||
}
|
||||
if t.SingboxVersion != nil {
|
||||
updates["singbox_version"] = *t.SingboxVersion
|
||||
cols = append(cols, agentColumn{"singbox_version", *t.SingboxVersion})
|
||||
}
|
||||
if t.UptimeSeconds != nil {
|
||||
updates["uptime_secs"] = *t.UptimeSeconds
|
||||
cols = append(cols, agentColumn{"uptime_secs", *t.UptimeSeconds})
|
||||
}
|
||||
if t.LatencyMs != nil {
|
||||
updates["latency_ms"] = *t.LatencyMs
|
||||
cols = append(cols, agentColumn{"latency_ms", *t.LatencyMs})
|
||||
}
|
||||
if t.CpuPercent != nil {
|
||||
updates["cpu_pct"] = *t.CpuPercent
|
||||
cols = append(cols, agentColumn{"cpu_pct", *t.CpuPercent})
|
||||
}
|
||||
if t.MemPercent != nil {
|
||||
updates["mem_pct"] = *t.MemPercent
|
||||
cols = append(cols, agentColumn{"mem_pct", *t.MemPercent})
|
||||
}
|
||||
if t.IPv4 != nil {
|
||||
updates["ipv4"] = *t.IPv4
|
||||
cols = append(cols, agentColumn{"ipv4", *t.IPv4})
|
||||
}
|
||||
if t.IPv6 != nil {
|
||||
updates["ipv6"] = *t.IPv6
|
||||
cols = append(cols, agentColumn{"ipv6", *t.IPv6})
|
||||
}
|
||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
_ = execAgentColumns(id, cols)
|
||||
}
|
||||
|
||||
// heartbeatProbeFacts / heartbeatProbeMetrics are the monitor-probe-style
|
||||
@@ -309,99 +356,101 @@ type heartbeatProbeMetrics struct {
|
||||
// metrics share one update so a heartbeat carries a single write; register
|
||||
// calls it with metrics == nil.
|
||||
func (a *AgentController) updateAgentProbe(id uint, f *heartbeatProbeFacts, m *heartbeatProbeMetrics) {
|
||||
updates := map[string]any{}
|
||||
var cols []agentColumn
|
||||
if f != nil {
|
||||
if f.Hostname != nil {
|
||||
updates["probe_hostname"] = *f.Hostname
|
||||
cols = append(cols, agentColumn{"probe_hostname", *f.Hostname})
|
||||
}
|
||||
if f.OS != nil {
|
||||
updates["probe_os"] = *f.OS
|
||||
cols = append(cols, agentColumn{"probe_os", *f.OS})
|
||||
}
|
||||
if f.Kernel != nil {
|
||||
updates["probe_kernel"] = *f.Kernel
|
||||
cols = append(cols, agentColumn{"probe_kernel", *f.Kernel})
|
||||
}
|
||||
if f.Arch != nil {
|
||||
updates["probe_arch"] = *f.Arch
|
||||
cols = append(cols, agentColumn{"probe_arch", *f.Arch})
|
||||
}
|
||||
if f.Virt != nil {
|
||||
updates["probe_virt"] = *f.Virt
|
||||
cols = append(cols, agentColumn{"probe_virt", *f.Virt})
|
||||
}
|
||||
if f.CPUName != nil {
|
||||
updates["probe_cpu_name"] = *f.CPUName
|
||||
cols = append(cols, agentColumn{"probe_cpu_name", *f.CPUName})
|
||||
}
|
||||
if f.CPUCores != nil {
|
||||
updates["probe_cpu_cores"] = *f.CPUCores
|
||||
cols = append(cols, agentColumn{"probe_cpu_cores", *f.CPUCores})
|
||||
}
|
||||
if f.MemTotal != nil {
|
||||
updates["probe_mem_total"] = *f.MemTotal
|
||||
cols = append(cols, agentColumn{"probe_mem_total", *f.MemTotal})
|
||||
}
|
||||
if f.SwapTotal != nil {
|
||||
updates["probe_swap_total"] = *f.SwapTotal
|
||||
cols = append(cols, agentColumn{"probe_swap_total", *f.SwapTotal})
|
||||
}
|
||||
if f.DiskTotal != nil {
|
||||
updates["probe_disk_total"] = *f.DiskTotal
|
||||
cols = append(cols, agentColumn{"probe_disk_total", *f.DiskTotal})
|
||||
}
|
||||
}
|
||||
if m != nil {
|
||||
updates["probe_at"] = time.Now().UnixMilli()
|
||||
cols = append(cols, agentColumn{"probe_at", time.Now().UnixMilli()})
|
||||
if m.BootID != nil {
|
||||
updates["probe_boot_id"] = *m.BootID
|
||||
cols = append(cols, agentColumn{"probe_boot_id", *m.BootID})
|
||||
}
|
||||
if m.Iface != nil {
|
||||
updates["probe_iface"] = *m.Iface
|
||||
cols = append(cols, agentColumn{"probe_iface", *m.Iface})
|
||||
}
|
||||
if m.Load != nil {
|
||||
updates["probe_load1"] = (*m.Load)[0]
|
||||
updates["probe_load5"] = (*m.Load)[1]
|
||||
updates["probe_load15"] = (*m.Load)[2]
|
||||
cols = append(cols,
|
||||
agentColumn{"probe_load1", (*m.Load)[0]},
|
||||
agentColumn{"probe_load5", (*m.Load)[1]},
|
||||
agentColumn{"probe_load15", (*m.Load)[2]})
|
||||
}
|
||||
if m.MemTotal != nil {
|
||||
updates["probe_mem_total"] = *m.MemTotal
|
||||
cols = append(cols, agentColumn{"probe_mem_total", *m.MemTotal})
|
||||
}
|
||||
if m.MemUsed != nil {
|
||||
updates["probe_mem_used"] = *m.MemUsed
|
||||
cols = append(cols, agentColumn{"probe_mem_used", *m.MemUsed})
|
||||
}
|
||||
if m.SwapTotal != nil {
|
||||
updates["probe_swap_total"] = *m.SwapTotal
|
||||
cols = append(cols, agentColumn{"probe_swap_total", *m.SwapTotal})
|
||||
}
|
||||
if m.SwapUsed != nil {
|
||||
updates["probe_swap_used"] = *m.SwapUsed
|
||||
cols = append(cols, agentColumn{"probe_swap_used", *m.SwapUsed})
|
||||
}
|
||||
if m.DiskTotal != nil {
|
||||
updates["probe_disk_total"] = *m.DiskTotal
|
||||
cols = append(cols, agentColumn{"probe_disk_total", *m.DiskTotal})
|
||||
}
|
||||
if m.DiskUsed != nil {
|
||||
updates["probe_disk_used"] = *m.DiskUsed
|
||||
cols = append(cols, agentColumn{"probe_disk_used", *m.DiskUsed})
|
||||
}
|
||||
if m.NetRxTotal != nil {
|
||||
updates["probe_net_rx_tot"] = *m.NetRxTotal
|
||||
cols = append(cols, agentColumn{"probe_net_rx_tot", *m.NetRxTotal})
|
||||
}
|
||||
if m.NetTxTotal != nil {
|
||||
updates["probe_net_tx_tot"] = *m.NetTxTotal
|
||||
cols = append(cols, agentColumn{"probe_net_tx_tot", *m.NetTxTotal})
|
||||
}
|
||||
if m.NetRx != nil {
|
||||
updates["probe_net_rx"] = *m.NetRx
|
||||
cols = append(cols, agentColumn{"probe_net_rx", *m.NetRx})
|
||||
}
|
||||
if m.NetTx != nil {
|
||||
updates["probe_net_tx"] = *m.NetTx
|
||||
cols = append(cols, agentColumn{"probe_net_tx", *m.NetTx})
|
||||
}
|
||||
if m.TCP != nil {
|
||||
updates["probe_tcp"] = *m.TCP
|
||||
cols = append(cols, agentColumn{"probe_tcp", *m.TCP})
|
||||
}
|
||||
if m.UDP != nil {
|
||||
updates["probe_udp"] = *m.UDP
|
||||
cols = append(cols, agentColumn{"probe_udp", *m.UDP})
|
||||
}
|
||||
if m.Procs != nil {
|
||||
updates["probe_procs"] = *m.Procs
|
||||
cols = append(cols, agentColumn{"probe_procs", *m.Procs})
|
||||
}
|
||||
if m.CPU != nil {
|
||||
updates["probe_cpu"] = *m.CPU
|
||||
cols = append(cols, agentColumn{"probe_cpu", *m.CPU})
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
if len(cols) == 0 {
|
||||
return
|
||||
}
|
||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
cols = append(cols, agentColumn{"updated_at", time.Now().UnixMilli()})
|
||||
_ = execAgentColumns(id, cols)
|
||||
}
|
||||
|
||||
// probeSample flattens a heartbeat metrics block into the probe
|
||||
@@ -467,31 +516,40 @@ type heartbeatUpdateStatus struct {
|
||||
// clear the intent so the config poll stops re-offering it; the recorded
|
||||
// state stays visible on the node row.
|
||||
func (a *AgentController) applyAgentUpdateStatus(id uint, st *heartbeatUpdateStatus) {
|
||||
db := database.GetDB()
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
// Only the intent stamp is needed to gate the report (ITEM-P3
|
||||
// M4: a two-column read instead of a whole-row First).
|
||||
sqlDB, err := database.GetDB().DB()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if row.UpdateRequestedAt == 0 || st.RequestedAt != row.UpdateRequestedAt {
|
||||
var requestedAt sql.NullInt64
|
||||
if err := sqlDB.QueryRow("SELECT update_requested_at FROM agent_nodes WHERE id = ?", id).Scan(&requestedAt); err != nil {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{"update_state_at": time.Now().UnixMilli()}
|
||||
if !requestedAt.Valid || requestedAt.Int64 == 0 || st.RequestedAt != requestedAt.Int64 {
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
cols := []agentColumn{{"update_state_at", now}, {"updated_at", now}}
|
||||
switch st.State {
|
||||
case "downloading", "swapped":
|
||||
updates["update_state"] = st.State
|
||||
updates["update_error"] = ""
|
||||
cols = append(cols,
|
||||
agentColumn{"update_state", st.State},
|
||||
agentColumn{"update_error", ""})
|
||||
case "failed":
|
||||
updates["update_state"] = "failed"
|
||||
updates["update_error"] = st.Error
|
||||
updates["update_requested_at"] = int64(0)
|
||||
cols = append(cols,
|
||||
agentColumn{"update_state", "failed"},
|
||||
agentColumn{"update_error", st.Error},
|
||||
agentColumn{"update_requested_at", int64(0)})
|
||||
case "current":
|
||||
updates["update_state"] = "current"
|
||||
updates["update_error"] = ""
|
||||
updates["update_requested_at"] = int64(0)
|
||||
cols = append(cols,
|
||||
agentColumn{"update_state", "current"},
|
||||
agentColumn{"update_error", ""},
|
||||
agentColumn{"update_requested_at", int64(0)})
|
||||
default:
|
||||
return
|
||||
}
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
_ = execAgentColumns(id, cols)
|
||||
}
|
||||
|
||||
// confirmAgentUpdate marks a pending intent successful once the agent
|
||||
@@ -501,23 +559,31 @@ func (a *AgentController) confirmAgentUpdate(id uint, reportedVersion string) {
|
||||
if reportedVersion == "" {
|
||||
return
|
||||
}
|
||||
db := database.GetDB()
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
// Only the pending intent's stamp and target gate the confirm
|
||||
// (ITEM-P3 M4: a two-column read instead of a whole-row First).
|
||||
sqlDB, err := database.GetDB().DB()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if row.UpdateRequestedAt == 0 || row.UpdateTarget == "" {
|
||||
var requestedAt sql.NullInt64
|
||||
var target sql.NullString
|
||||
if err := sqlDB.QueryRow("SELECT update_requested_at, update_target FROM agent_nodes WHERE id = ?", id).Scan(&requestedAt, &target); err != nil {
|
||||
return
|
||||
}
|
||||
if cmp, ok := config.CompareDisplayVersion(reportedVersion, row.UpdateTarget); !ok || cmp < 0 {
|
||||
if !requestedAt.Valid || requestedAt.Int64 == 0 || !target.Valid || target.String == "" {
|
||||
return
|
||||
}
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"update_state": "success",
|
||||
"update_error": "",
|
||||
"update_requested_at": int64(0),
|
||||
"update_state_at": time.Now().UnixMilli(),
|
||||
}).Error
|
||||
if cmp, ok := config.CompareDisplayVersion(reportedVersion, target.String); !ok || cmp < 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
_ = execAgentColumns(id, []agentColumn{
|
||||
{"update_state", "success"},
|
||||
{"update_error", ""},
|
||||
{"update_requested_at", int64(0)},
|
||||
{"update_state_at", now},
|
||||
{"updated_at", now},
|
||||
})
|
||||
}
|
||||
|
||||
// heartbeatProbeResult is one latency sample an agent piggybacks on
|
||||
@@ -626,11 +692,16 @@ func probeStatIface(agentID int64) string {
|
||||
}
|
||||
|
||||
// probeReport ingests one fast report (V2.0.1): a probe metrics
|
||||
// block and nothing else. It deliberately does NOT touch the
|
||||
// agent's last-seen stamp — liveness stays the 60 s heartbeat's
|
||||
// signal, and the traffic ledger's unseen-boot rule keeps measuring
|
||||
// reboots against heartbeat sightings exactly as before. Agents on
|
||||
// older masters never see this endpoint (404) and simply keep
|
||||
// block and nothing else. Since V2.0.3 it also touches the agent's
|
||||
// last-seen stamp, like every other report channel: the hub's
|
||||
// themes gate each batch on the node's last_seen being newer than
|
||||
// the last one they applied, so a metrics-only channel that never
|
||||
// advanced the stamp froze the public cards between the 10 s
|
||||
// traffic reports. Liveness now means "any report from the agent"
|
||||
// — all channels come from the same process — and the traffic
|
||||
// ledger's unseen-boot rule is unaffected: Record still receives
|
||||
// the pre-touch stamp, so gaps measure exactly as before. Agents
|
||||
// on older masters never see this endpoint (404) and simply keep
|
||||
// reporting metrics on the heartbeat; masters accept metrics on
|
||||
// either channel.
|
||||
func (a *AgentController) probeReport(c *gin.Context) {
|
||||
@@ -644,9 +715,13 @@ func (a *AgentController) probeReport(c *gin.Context) {
|
||||
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
// The last-seen stamp as it stood before this report (see the
|
||||
// heartbeat handler): captured before touchAgent overwrites it.
|
||||
prevLastSeen := agent.LastSeen
|
||||
a.touchAgent(uint(agent.Id), "")
|
||||
if req.ProbeMetrics != nil {
|
||||
a.updateAgentProbe(uint(agent.Id), nil, req.ProbeMetrics)
|
||||
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), agent.LastSeen)
|
||||
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
|
||||
}
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
@@ -662,22 +737,37 @@ 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
|
||||
|
||||
@@ -18,9 +18,10 @@ import (
|
||||
)
|
||||
|
||||
// V2.0.1 probe batch, master side: the fast-report endpoint ingests
|
||||
// metrics without touching liveness (last-seen stays the
|
||||
// heartbeat's), and the config poll carries the cadence + the
|
||||
// node's counted-interface pin.
|
||||
// metrics, and the config poll carries the cadence + the node's
|
||||
// counted-interface pin. Since V2.0.3 the fast report also stamps
|
||||
// last-seen (the hub's themes gate batches on its freshness); it
|
||||
// originally did not — see the probeReport handler comment.
|
||||
func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
@@ -71,6 +72,12 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
||||
|
||||
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
|
||||
`"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}`
|
||||
// Age the stamp first so the fast report's touch is observable
|
||||
// even within the same millisecond as the heartbeat's.
|
||||
stale := seenAfterHeartbeat - 120_000
|
||||
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil {
|
||||
t.Fatalf("age last-seen: %v", err)
|
||||
}
|
||||
if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK {
|
||||
t.Fatalf("probe-report: %d (%s)", code, body)
|
||||
}
|
||||
@@ -78,8 +85,8 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
||||
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
|
||||
t.Errorf("fast report metrics not stored: %+v", row)
|
||||
}
|
||||
if row.LastSeen != seenAfterHeartbeat {
|
||||
t.Errorf("fast report moved last-seen: %d -> %d (liveness is the heartbeat's signal)", seenAfterHeartbeat, row.LastSeen)
|
||||
if row.LastSeen <= stale {
|
||||
t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, row.LastSeen)
|
||||
}
|
||||
|
||||
// Unauthorized callers are refused.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Windows agent freeze (version decoupling): the Windows agent line is
|
||||
// frozen at A2.2.1 — exactly one machine (TYYUN, probe-only) runs it
|
||||
// and no new Windows builds will ship. The frozen agent can therefore
|
||||
// never be updated to match a future master, so the MASTER side of
|
||||
// its protocol is under contract instead: the request shapes the
|
||||
// A2.2.1 Windows agent sends (register / heartbeat / fast report) and
|
||||
// the response shapes it consumes (envelopes, config payload keys)
|
||||
// must keep working verbatim. Any change that breaks this test is a
|
||||
// breaking change for a node that cannot be upgraded — see
|
||||
// docs/release.md ("Windows freeze").
|
||||
//
|
||||
// The payloads below mirror what cmd/xui-agent-windows at A2.2.1
|
||||
// actually emits, including its documented degradations (V2.2.1):
|
||||
// no xray/singbox version in telemetry, load and TCP/UDP counts
|
||||
// pinned to zero, no updateStatus ever.
|
||||
func TestAgentLegacyWindows221Contract(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()
|
||||
|
||||
node := &model.Node{Name: "tyyun-frozen", Address: "10.0.22.1", Enable: true}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
const token = "legacy-windows-token"
|
||||
agent := &model.AgentNode{
|
||||
TokenHash: hashAgentToken(token), TokenPrefix: "legacy-",
|
||||
Name: "tyyun-frozen", NodeID: &node.Id, ProbeOnly: true,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// --- register, with the probe facts a Windows agent reports -----
|
||||
regBody := `{"token":"` + token + `","name":"tyyun-frozen","probeFacts":{` +
|
||||
`"hostname":"TYYUN-PC","os":"Windows 10","kernel":"10.0.19045","arch":"amd64",` +
|
||||
`"virt":"kvm","cpuName":"Intel Xeon","cpuCores":4,` +
|
||||
`"memTotal":4294967296,"swapTotal":0,"diskTotal":64424509440,` +
|
||||
`"agentVersion":"A2.2.1"}}`
|
||||
code, body := call(http.MethodPost, "/panel/api/agent/register", regBody)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("register: %d (%s)", code, body)
|
||||
}
|
||||
var regEnv struct {
|
||||
Success bool `json:"success"`
|
||||
Obj struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
if err := json.Unmarshal(body, ®Env); err != nil || !regEnv.Success || regEnv.Obj.Message != "registered" {
|
||||
t.Fatalf("register envelope changed shape: %s", body)
|
||||
}
|
||||
if row := load(); row.ProbeHostname != "TYYUN-PC" || row.LastSeen == 0 {
|
||||
t.Fatalf("register facts/last-seen not stored: %+v", row)
|
||||
}
|
||||
|
||||
// --- heartbeat: telemetry (no core versions), facts + metrics --
|
||||
hbBody := `{"token":"` + token + `","timestamp":1750000000,` +
|
||||
`"telemetry":{"agentVersion":"A2.2.1","uptimeSeconds":90061,` +
|
||||
`"cpuPercent":3.5,"memPercent":41.2,"ipv4":"203.0.113.9"},` +
|
||||
`"probeFacts":{"hostname":"TYYUN-PC","os":"Windows 10","kernel":"10.0.19045",` +
|
||||
`"arch":"amd64","virt":"kvm","cpuName":"Intel Xeon","cpuCores":4,` +
|
||||
`"memTotal":4294967296,"swapTotal":0,"diskTotal":64424509440},` +
|
||||
`"probeMetrics":{"bootId":"win-boot-1","iface":"Ethernet","uptime":90061,` +
|
||||
`"load":[0,0,0],"memTotal":4294967296,"memUsed":1769803776,` +
|
||||
`"swapTotal":0,"swapUsed":0,"diskTotal":64424509440,"diskUsed":21474836480,` +
|
||||
`"netRxTotal":1073741824,"netTxTotal":536870912,"netRx":1024,"netTx":2048,` +
|
||||
`"tcp":0,"udp":0,"procs":88,"cpu":3.5}}`
|
||||
code, body = call(http.MethodPost, "/panel/api/agent/heartbeat", hbBody)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("heartbeat: %d (%s)", code, body)
|
||||
}
|
||||
row := load()
|
||||
if row.AgentVersion != "A2.2.1" {
|
||||
t.Fatalf("agent version not stored from legacy heartbeat: %q", row.AgentVersion)
|
||||
}
|
||||
if row.TelemetryAt == 0 || row.ProbeAt == 0 {
|
||||
t.Fatalf("legacy heartbeat did not stamp telemetry/probe times: %+v", row)
|
||||
}
|
||||
|
||||
// --- fast report (probe-report): metrics only --------------------
|
||||
frBody := `{"probeMetrics":{"bootId":"win-boot-1","iface":"Ethernet","uptime":90121,` +
|
||||
`"load":[0,0,0],"memTotal":4294967296,"memUsed":1782579200,` +
|
||||
`"swapTotal":0,"swapUsed":0,"diskTotal":64424509440,"diskUsed":21474836480,` +
|
||||
`"netRxTotal":1074790400,"netTxTotal":537395200,"netRx":2048,"netTx":1024,` +
|
||||
`"tcp":0,"udp":0,"procs":89,"cpu":4.0}}`
|
||||
code, body = call(http.MethodPost, "/panel/api/agent/probe-report", frBody)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("probe-report: %d (%s)", code, body)
|
||||
}
|
||||
if row := load(); row.ProbeMemUsed != 1782579200 {
|
||||
t.Fatalf("fast report metrics not stored: %+v", row)
|
||||
}
|
||||
|
||||
// --- config poll: the frozen agent consumes these keys ----------
|
||||
code, body = call(http.MethodGet, "/panel/api/agent/config", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("config: %d (%s)", code, body)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
t.Fatalf("config payload not a JSON object: %v", err)
|
||||
}
|
||||
// Probe-only: no core payload, but the probe keys must be present
|
||||
// with their established types — the frozen agent keys its cadence
|
||||
// and interface pin off them.
|
||||
if cfg["probe_only"] != true {
|
||||
t.Fatalf("probe_only = %v, want true", cfg["probe_only"])
|
||||
}
|
||||
if v, present := cfg["singbox_config"]; !present || v != nil {
|
||||
t.Fatalf("singbox_config = %v (present %v), want present null for probe-only", v, present)
|
||||
}
|
||||
if _, present := cfg["probe_tasks"]; !present {
|
||||
t.Fatal("probe_tasks key missing from config payload")
|
||||
}
|
||||
if _, ok := cfg["probe_fast_interval"].(float64); !ok {
|
||||
t.Fatalf("probe_fast_interval = %v, want a number", cfg["probe_fast_interval"])
|
||||
}
|
||||
if _, ok := cfg["probe_stat_iface"].(string); !ok {
|
||||
t.Fatalf("probe_stat_iface = %v, want a string", cfg["probe_stat_iface"])
|
||||
}
|
||||
if _, present := cfg["agent_update"]; present {
|
||||
t.Fatal("no intent was recorded, so agent_update must be absent")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -35,18 +35,6 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
|
||||
}
|
||||
|
||||
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||
// A verified client certificate (a completed mTLS handshake) authenticates
|
||||
// the caller, equivalent to a valid bearer token. api_authed must be set so
|
||||
// the CSRF middleware lets cert-authed mutations through.
|
||||
if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
|
||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
c.Set("api_authed", true)
|
||||
c.Set("api_token_scope", model.ApiScopeNodeSync)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
auth := c.GetHeader("Authorization")
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
tok := after
|
||||
@@ -79,33 +67,7 @@ var monitorScopeAllow = map[string]struct{}{
|
||||
"/nodes/history/:id/:metric/:bucket": {},
|
||||
}
|
||||
|
||||
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
|
||||
// /panel/api; Gin patterns prevent concrete parameters broadening authority.
|
||||
var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
||||
"/server/status": {http.MethodGet: {}},
|
||||
"/inbounds/list": {http.MethodGet: {}},
|
||||
"/inbounds/add": {http.MethodPost: {}},
|
||||
"/inbounds/del/:id": {http.MethodPost: {}},
|
||||
"/inbounds/update/:id": {http.MethodPost: {}},
|
||||
"/clients/add": {http.MethodPost: {}},
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
||||
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
||||
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
||||
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
||||
"/clients/onlines": {http.MethodPost: {}},
|
||||
"/clients/lastOnline": {http.MethodPost: {}},
|
||||
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
||||
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
||||
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
||||
"/hosts/list": {http.MethodGet: {}},
|
||||
}
|
||||
|
||||
// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
|
||||
// enforceTokenScope applies the explicit allowlist to monitor tokens.
|
||||
// Admin tokens and session-login users retain their existing behavior.
|
||||
func (a *APIController) enforceTokenScope(c *gin.Context) {
|
||||
scopeVal, ok := c.Get("api_token_scope")
|
||||
@@ -131,13 +93,6 @@ func (a *APIController) enforceTokenScope(c *gin.Context) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
case model.ApiScopeNodeSync:
|
||||
if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
|
||||
if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
deny()
|
||||
return
|
||||
|
||||
@@ -2,12 +2,10 @@ package controller
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -110,100 +108,9 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
|
||||
// as node-sync rather than bypassing scope checks as admin.
|
||||
func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
|
||||
engine, _ := newAPIAuthTestEngine(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
|
||||
req.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != `{"api_authed":true,"scope":"node-sync"}` {
|
||||
t.Fatalf("body = %s, want node-sync scope", got)
|
||||
}
|
||||
|
||||
forbidden := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
|
||||
forbidden.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, forbidden)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
|
||||
expected := map[string]map[string]struct{}{
|
||||
"/server/status": {http.MethodGet: {}},
|
||||
"/inbounds/list": {http.MethodGet: {}},
|
||||
"/inbounds/add": {http.MethodPost: {}},
|
||||
"/inbounds/del/:id": {http.MethodPost: {}},
|
||||
"/inbounds/update/:id": {http.MethodPost: {}},
|
||||
"/clients/add": {http.MethodPost: {}},
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
||||
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
|
||||
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
|
||||
"/clients/onlinesByGuid": {http.MethodPost: {}},
|
||||
"/clients/onlines": {http.MethodPost: {}},
|
||||
"/clients/lastOnline": {http.MethodPost: {}},
|
||||
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
|
||||
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
|
||||
"/clients/clientIpsByGuid": {http.MethodPost: {}},
|
||||
"/hosts/list": {http.MethodGet: {}},
|
||||
}
|
||||
if !reflect.DeepEqual(nodeSyncScopeAllow, expected) {
|
||||
t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected)
|
||||
}
|
||||
if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok {
|
||||
t.Fatal("node-sync must not include /server/updatePanel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
|
||||
engine, _ := newAPIAuthTestEngine(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"detach email parameter", http.MethodPost, "/panel/api/clients/alice@example.com/detach", http.StatusOK},
|
||||
{"reset inbound id parameter", http.MethodPost, "/panel/api/inbounds/42/resetTraffic", http.StatusOK},
|
||||
{"client IP by guid endpoint", http.MethodPost, "/panel/api/clients/clientIpsByGuid", http.StatusOK},
|
||||
{"update panel forbidden", http.MethodPost, "/panel/api/server/updatePanel", http.StatusForbidden},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
req.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough asserts a TLS request with no
|
||||
// verified client chain is NOT treated as authenticated (it falls through to the
|
||||
// bearer/session paths) — so the cert branch can't accidentally authorize plain
|
||||
// browser HTTPS.
|
||||
// bearer/session paths) — a plain browser HTTPS connection must never authorize.
|
||||
func TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough(t *testing.T) {
|
||||
engine, _ := newAPIAuthTestEngine(t)
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.DELETE("/hwids/:email/:id", a.deleteHwid)
|
||||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/onlinesByGuid", a.onlinesByGuid)
|
||||
g.POST("/clientIpsByGuid", a.clientIpsByGuid)
|
||||
g.POST("/activeInbounds", a.activeInbounds)
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
}
|
||||
@@ -523,11 +522,6 @@ func (a *ClientController) getIps(c *gin.Context) {
|
||||
jsonObj(c, infos, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) clientIpsByGuid(c *gin.Context) {
|
||||
data, err := a.inboundService.GetClientIpsByGuid()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) clearIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
if err := a.inboundService.ClearClientIps(email); err != nil {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -84,7 +83,6 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/import", a.importInbound)
|
||||
g.POST("/:id/fallbacks", a.setFallbacks)
|
||||
g.POST("/pushClientTraffics", a.pushClientTraffics)
|
||||
}
|
||||
|
||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||
@@ -474,24 +472,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||
}
|
||||
|
||||
// pushClientTraffics receives a master panel's aggregated per-client usage
|
||||
// (see InboundService.AcceptGlobalTraffic for the storage semantics).
|
||||
func (a *InboundController) pushClientTraffics(c *gin.Context) {
|
||||
var req struct {
|
||||
MasterGuid string `json:"masterGuid"`
|
||||
Traffics []*xray.ClientTraffic `json:"traffics"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, "success", nil)
|
||||
}
|
||||
|
||||
// importInbound imports an inbound configuration from provided data.
|
||||
func (a *InboundController) importInbound(c *gin.Context) {
|
||||
inbound := &model.Inbound{}
|
||||
|
||||
+29
-193
@@ -2,12 +2,14 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"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"
|
||||
|
||||
@@ -28,62 +30,15 @@ func NewNodeController(g *gin.RouterGroup) *NodeController {
|
||||
func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.GET("/webCert/:id", a.webCert)
|
||||
|
||||
g.POST("/add", a.add)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.del)
|
||||
g.POST("/setEnable/:id", a.setEnable)
|
||||
|
||||
g.POST("/test", a.test)
|
||||
g.POST("/certFingerprint", a.certFingerprint)
|
||||
g.POST("/inbounds", a.inbounds)
|
||||
g.POST("/probe/:id", a.probe)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.GET("/history/:id/:metric/:bucket", a.history)
|
||||
g.POST("/mtls/ca", a.mtlsCa)
|
||||
g.POST("/mtls/trustCA", a.setMtlsTrustCA)
|
||||
g.POST("/mtls/reloadClient", a.reloadMtlsClient)
|
||||
}
|
||||
|
||||
// reloadMtlsClient validates the credential currently stored by the master and
|
||||
// closes cached mTLS pools so subsequent node requests present the new leaf.
|
||||
func (a *NodeController) reloadMtlsClient(c *gin.Context) {
|
||||
if err := a.nodeService.ReloadMasterMtlsClient(); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), nil)
|
||||
}
|
||||
|
||||
// mtlsCa returns this panel's node-auth CA certificate (public) to paste into a
|
||||
// node's mTLS trust setting. It lazily mints the CA + master client cert on
|
||||
// first call.
|
||||
func (a *NodeController) mtlsCa(c *gin.Context) {
|
||||
caCert, err := a.nodeService.NodeMtlsCaCert()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"caCert": caCert}, nil)
|
||||
}
|
||||
|
||||
// setMtlsTrustCA stores the CA this panel trusts for incoming node-API client
|
||||
// certificates (this panel acting as a node). An empty value disables it.
|
||||
// Applied on the next panel restart.
|
||||
func (a *NodeController) setMtlsTrustCA(c *gin.Context) {
|
||||
var req struct {
|
||||
CaCert string `json:"caCert" form:"caCert"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.SetNodeMtlsTrustCA(req.CaCert); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) list(c *gin.Context) {
|
||||
@@ -109,77 +64,44 @@ func (a *NodeController) get(c *gin.Context) {
|
||||
jsonObj(c, n, nil)
|
||||
}
|
||||
|
||||
// webCert returns the node's own web TLS certificate/key file paths so the
|
||||
// inbound form's "Set Cert from Panel" can fill paths that exist on the node.
|
||||
func (a *NodeController) webCert(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
files, err := a.nodeService.GetWebCertFiles(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, files, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error {
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil {
|
||||
return errors.New(service.FriendlyProbeError(err.Error()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *NodeController) add(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
isAgent := n.NodeType == "agent"
|
||||
// Agent nodes have no panel to probe; they authenticate via agent token.
|
||||
if n.OutboundTag == "" && !isAgent {
|
||||
if err := a.ensureReachable(c, n, 0); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
view, err := a.nodeService.CreateFromRequest(n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
if n.OutboundTag != "" {
|
||||
if err := a.ensureReachable(c, n, view.Id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// For agent nodes, issue an agent token bound to the new node and
|
||||
// return it so the UI can display it (shown only once).
|
||||
if isAgent {
|
||||
view.NodeType = "agent"
|
||||
nodeID := view.Id
|
||||
token, _, err := issueAgentToken(&nodeID, n.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
||||
"node": view,
|
||||
"agentToken": token,
|
||||
"nodeType": "agent",
|
||||
}, nil)
|
||||
// Every node is agent-managed: issue an agent token bound to the
|
||||
// new node and return it so the UI can display it (shown only once).
|
||||
view.NodeType = "agent"
|
||||
nodeID := view.Id
|
||||
token, agentID, err := issueAgentToken(&nodeID, n.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
|
||||
// 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,
|
||||
"nodeType": "agent",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) update(c *gin.Context) {
|
||||
@@ -192,30 +114,10 @@ func (a *NodeController) update(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
old, err := a.nodeService.GetById(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
// Agent nodes have no panel to probe and no panel API token; editing one
|
||||
// must skip the reachability check exactly like creation does.
|
||||
isAgent := n.NodeType == "agent" || a.nodeService.IsAgentManaged(id)
|
||||
if !isAgent && n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
|
||||
if err := a.ensureReachable(c, n, id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.nodeService.UpdateFromRequest(id, n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if n.OutboundTag != old.OutboundTag {
|
||||
if err := a.ensureReachable(c, n, id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||
}
|
||||
|
||||
@@ -245,79 +147,13 @@ func (a *NodeController) setEnable(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
n, err := a.nodeService.GetById(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if n.OutboundTag != "" {
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) inbounds(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode)
|
||||
jsonObj(c, options, err)
|
||||
}
|
||||
|
||||
func (a *NodeController) test(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
var patch service.HeartbeatPatch
|
||||
if runtimeNode.OutboundTag != "" {
|
||||
patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag)
|
||||
} else {
|
||||
patch, err = a.nodeService.Probe(ctx, runtimeNode)
|
||||
}
|
||||
jsonObj(c, patch.ToUI(err == nil), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) certFingerprint(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, fp, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) probe(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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/locale"
|
||||
)
|
||||
|
||||
func newNodeCredentialTestEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
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() })
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
|
||||
c.Next()
|
||||
})
|
||||
NewNodeController(engine.Group("/panel/api/nodes"))
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
if err := database.GetDB().Create(&model.Node{
|
||||
Name: "stored-node",
|
||||
Scheme: "https",
|
||||
Address: "example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "stored-secret-token",
|
||||
Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} {
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") {
|
||||
t.Fatalf("%s leaked api token: %s", path, body)
|
||||
}
|
||||
if !strings.Contains(body, `"hasApiToken":true`) {
|
||||
t.Fatalf("%s did not expose credential presence: %s", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerProbeReportsHeartbeatPersistenceFailure(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.6.0","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
node := &model.Node{Scheme: "http", Address: host, Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
const callback = "test:fail_node_heartbeat_update"
|
||||
errInjected := errors.New("injected heartbeat persistence failure")
|
||||
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && tx.Statement.Table == "nodes" {
|
||||
tx.AddError(errInjected)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register update callback: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Callback().Update().Remove(callback); err != nil {
|
||||
t.Errorf("remove update callback: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/panel/api/nodes/probe/"+strconv.Itoa(node.Id), nil))
|
||||
if !strings.Contains(w.Body.String(), `"success":false`) {
|
||||
t.Fatalf("probe reported success despite heartbeat persistence failure: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), errInjected.Error()) {
|
||||
t.Fatalf("probe response omitted persistence error: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/panel/api/server/status" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "added-node",
|
||||
"scheme": "http",
|
||||
"address": host,
|
||||
"port": port,
|
||||
"basePath": "/",
|
||||
"apiToken": "input-secret-token",
|
||||
"enable": true,
|
||||
"allowPrivateAddress": true,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("add status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") {
|
||||
t.Fatalf("add response leaked api token: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"hasApiToken":true`) {
|
||||
t.Fatalf("add response did not expose credential presence: %s", body)
|
||||
}
|
||||
|
||||
var stored model.Node
|
||||
if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil {
|
||||
t.Fatalf("load stored node: %v", err)
|
||||
}
|
||||
if stored.ApiToken != "input-secret-token" {
|
||||
t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(t *testing.T) {
|
||||
engine := newNodeCredentialTestEngine(t)
|
||||
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/panel/api/server/status" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer stored-secret-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
|
||||
}))
|
||||
defer remote.Close()
|
||||
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split remote addr: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portString)
|
||||
if err != nil {
|
||||
t.Fatalf("parse remote port: %v", err)
|
||||
}
|
||||
node := &model.Node{
|
||||
Name: "stored-node",
|
||||
Scheme: "http",
|
||||
Address: host,
|
||||
Port: port,
|
||||
BasePath: "/",
|
||||
ApiToken: "stored-secret-token",
|
||||
Enable: true,
|
||||
AllowPrivateAddress: true,
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "stored-node-renamed",
|
||||
"scheme": "http",
|
||||
"address": host,
|
||||
"port": port,
|
||||
"basePath": "/",
|
||||
"apiToken": "",
|
||||
"enable": true,
|
||||
"allowPrivateAddress": true,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var stored model.Node
|
||||
if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil {
|
||||
t.Fatalf("load stored node: %v", err)
|
||||
}
|
||||
if stored.ApiToken != "stored-secret-token" {
|
||||
t.Fatalf("blank update changed token to %q", stored.ApiToken)
|
||||
}
|
||||
if stored.Name != "stored-node-renamed" {
|
||||
t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,10 @@ type probeMetaForm struct {
|
||||
IPv4Pin string `json:"ipv4Pin"`
|
||||
IPv6Pin string `json:"ipv6Pin"`
|
||||
// V2.0.1: traffic calibration (GB, either sign) and the counted
|
||||
// interface pin ("" = automatic).
|
||||
// interface pin ("" = automatic). Since V2.2.1 ⑥b the calibration
|
||||
// fields are ONE-SHOT deltas: a non-zero value is folded into the
|
||||
// stored standing correction on save (see probe.MergeCalDelta);
|
||||
// zero/absent applies nothing.
|
||||
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
|
||||
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
|
||||
StatIface string `json:"statIface"`
|
||||
@@ -75,8 +78,11 @@ func metaToForm(m probe.Meta) probeMetaForm {
|
||||
CountryPin: m.CountryPin,
|
||||
IPv4Pin: m.IPv4Pin,
|
||||
IPv6Pin: m.IPv6Pin,
|
||||
TrafficCalUpGB: m.TrafficCalUpGB,
|
||||
TrafficCalDownGB: m.TrafficCalDownGB,
|
||||
// One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
|
||||
// boxes always start empty; the standing correction folded
|
||||
// into the stored row is never echoed back into them.
|
||||
TrafficCalUpGB: 0,
|
||||
TrafficCalDownGB: 0,
|
||||
StatIface: m.StatIface,
|
||||
}
|
||||
}
|
||||
@@ -150,7 +156,16 @@ func (pc *ProbeMetaController) putMeta(c *gin.Context) {
|
||||
jsonMsg(c, msg, errRequest)
|
||||
return
|
||||
}
|
||||
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
|
||||
// Calibration is one-shot (V2.2.1 ⑥b): fold the submitted deltas
|
||||
// into the stored standing correction; all other fields replace.
|
||||
meta := form.toMeta(int64(agent.Id))
|
||||
if existing, found, err := probe.MetaFor(int64(agent.Id)); err != nil {
|
||||
jsonMsg(c, "save probe meta failed", err)
|
||||
return
|
||||
} else if found {
|
||||
meta = probe.MergeCalDelta(existing, meta)
|
||||
}
|
||||
if err := probe.SaveMeta(meta); err != nil {
|
||||
jsonMsg(c, "save probe meta failed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -252,6 +252,9 @@ var anonNodeKeys = []string{
|
||||
"traffic_limit", "traffic_mode", "traffic_reset_day",
|
||||
"total_rx", "total_tx", "month_rx", "month_tx", "month_used",
|
||||
"month_start", "day_rx", "day_tx",
|
||||
// B6 rider: ipv4/ipv6 exist in the anonymous shape as stack
|
||||
// markers only ("" or "1"), never as addresses.
|
||||
"ipv4", "ipv6",
|
||||
}
|
||||
|
||||
var metricKeys = []string{
|
||||
@@ -303,7 +306,7 @@ func TestProbePublicContract(t *testing.T) {
|
||||
body := rec.Body.String()
|
||||
for _, leak := range []string{
|
||||
"secret-host", "private-remark", `"hostname"`, `"remark"`,
|
||||
`"ip":`, `"ipv4"`, `"ipv6"`, "203.0.113.9", "198.51.100.4", "eth0",
|
||||
`"ip":`, "203.0.113.9", "198.51.100.4", "eth0",
|
||||
} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("anonymous frame leaks %q: %s", leak, body)
|
||||
@@ -323,7 +326,15 @@ func TestProbePublicContract(t *testing.T) {
|
||||
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
|
||||
var node map[string]any
|
||||
_ = json.Unmarshal(nodeBody, &node)
|
||||
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
|
||||
// B6 rider: the seed node has a v4 stack and no v6 stack, so the
|
||||
// anonymous stack markers are exactly "1" and "" — never addresses
|
||||
// (the real addresses stay in the leak list above).
|
||||
if node["ipv4"] != "1" || node["ipv6"] != "" {
|
||||
t.Fatalf("anon stack markers = %v/%v, want \"1\"/\"\"", node["ipv4"], node["ipv6"])
|
||||
}
|
||||
// V2.2.1 ⑦: the public view's name is the public remark when one
|
||||
// is set (the seed sets "shown publicly"), not the node name.
|
||||
if node["id"] != float64(pubID) || node["name"] != "shown publicly" || node["online"] != true ||
|
||||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
|
||||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
|
||||
t.Fatalf("anon node values wrong: %v", node)
|
||||
@@ -599,3 +610,61 @@ func TestProbeWebSocket(t *testing.T) {
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
// Q7: the resolved country reaches both frames. The seed's held
|
||||
// address is a documentation range (unlisted in any country DB), so
|
||||
// repoint it at a real public address whose country the embedded
|
||||
// DB-IP snapshot knows — 8.8.8.8 is US there.
|
||||
func TestProbePublicCountryFlag(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
pubID, _ := rig.seedAgent(t, "FlagNode", true)
|
||||
|
||||
db := database.GetDB()
|
||||
var agent model.AgentNode
|
||||
if err := db.First(&agent, pubID).Error; err != nil {
|
||||
t.Fatalf("load seeded agent: %v", err)
|
||||
}
|
||||
agent.IPv4 = "8.8.8.8"
|
||||
if err := db.Save(&agent).Error; err != nil {
|
||||
t.Fatalf("repoint agent address: %v", err)
|
||||
}
|
||||
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
find := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var frame struct {
|
||||
Nodes []map[string]any `json:"nodes"`
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
|
||||
t.Fatalf("frame decode: %v", err)
|
||||
}
|
||||
for _, n := range frame.Nodes {
|
||||
if n["id"] == float64(pubID) {
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("seeded node missing from frame: %s", rec.Body.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
rec := rig.do(http.MethodGet, "/api/nodes", nil, false)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes anon: %d", rec.Code)
|
||||
}
|
||||
if got := find(rec)["country"]; got != "US" {
|
||||
t.Fatalf("anonymous country = %v, want US", got)
|
||||
}
|
||||
|
||||
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes admin: %d", rec.Code)
|
||||
}
|
||||
admin := find(rec)
|
||||
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
|
||||
t.Fatalf("admin country triple = (%v, %v, %v), want (US, US, empty)",
|
||||
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"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/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
@@ -43,17 +42,13 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/history/:metric/:bucket", a.getOverviewHistoryBucket)
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
g.GET("/getDb", a.getDb)
|
||||
g.GET("/getMigration", a.getMigration)
|
||||
g.GET("/getNewUUID", a.getNewUUID)
|
||||
g.GET("/getWebCertFiles", a.getWebCertFiles)
|
||||
g.GET("/descendants", a.descendants)
|
||||
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
|
||||
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
||||
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
||||
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
|
||||
g.GET("/clientIps", a.getClientIps)
|
||||
g.GET("/fail2banStatus", a.getFail2banStatus)
|
||||
g.GET("/outboundDirectory", a.getOutboundDirectory)
|
||||
|
||||
@@ -65,7 +60,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/getRemoteCertHash", a.getRemoteCertHash)
|
||||
g.POST("/scanRealityTarget", a.scanRealityTarget)
|
||||
g.POST("/scanRealityTargets", a.scanRealityTargets)
|
||||
g.POST("/clientIps", a.setClientIps)
|
||||
}
|
||||
|
||||
// startTask registers the @2s ticker that refreshes server status and pushes
|
||||
@@ -164,16 +158,6 @@ func (a *ServerController) setUpdateChannel(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
|
||||
}
|
||||
|
||||
// getConfigJson retrieves the Xray configuration as JSON.
|
||||
func (a *ServerController) getConfigJson(c *gin.Context) {
|
||||
configJson, err := a.serverService.GetConfigJson()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.getConfigError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, configJson, nil)
|
||||
}
|
||||
|
||||
// getDb downloads the database file.
|
||||
func (a *ServerController) getDb(c *gin.Context) {
|
||||
db, err := a.serverService.GetDb()
|
||||
@@ -230,32 +214,6 @@ func (a *ServerController) importDB(c *gin.Context) {
|
||||
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
||||
}
|
||||
|
||||
// descendants publishes read-only summaries of the nodes this panel manages so
|
||||
// a parent panel can surface them as transitive sub-nodes in a chained
|
||||
// topology. Called by the parent via the node's API token (#4983).
|
||||
func (a *ServerController) descendants(c *gin.Context) {
|
||||
data, err := (&service.NodeService{}).LocalDescendants()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
// getWebCertFiles returns this panel's own web TLS certificate and key file
|
||||
// paths. The central panel calls it on a node (via the node's API token) so
|
||||
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
|
||||
// on the node's filesystem instead of the central panel's — see issue #4854.
|
||||
func (a *ServerController) getWebCertFiles(c *gin.Context) {
|
||||
certFile, err := a.settingService.GetCertFile()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
keyFile, err := a.settingService.GetKeyFile()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil)
|
||||
}
|
||||
|
||||
// getNewX25519Cert generates a new X25519 certificate.
|
||||
func (a *ServerController) getNewX25519Cert(c *gin.Context) {
|
||||
cert, err := a.serverService.GetNewX25519Cert()
|
||||
@@ -419,18 +377,3 @@ func (a *ServerController) getNewmlkem768(c *gin.Context) {
|
||||
}
|
||||
jsonObj(c, out, nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getClientIps(c *gin.Context) {
|
||||
ips, err := (&service.InboundService{}).GetAllInboundClientIps()
|
||||
jsonObj(c, ips, err)
|
||||
}
|
||||
|
||||
func (a *ServerController) setClientIps(c *gin.Context) {
|
||||
var ips []model.InboundClientIps
|
||||
if err := c.ShouldBindJSON(&ips); err != nil {
|
||||
jsonMsg(c, "invalid data", err)
|
||||
return
|
||||
}
|
||||
err := (&service.InboundService{}).MergeInboundClientIps(ips)
|
||||
jsonMsg(c, "Client IPs merged", err)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeNodeSync}
|
||||
row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeMonitor}
|
||||
if err := database.GetDB().Create(row).Error; err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
@@ -86,4 +86,3 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
|
||||
t.Fatal("token was disabled by wrong scope")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ type AllSetting struct {
|
||||
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
||||
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
|
||||
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
|
||||
// TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic
|
||||
// accounting switches as one JSON string
|
||||
// ({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// GeoIPCountryJob is the weekly refresh of the local country
|
||||
// database behind the public pages' node flags (Q7: a local MMDB on
|
||||
// the master, never an external per-node lookup). The service does
|
||||
// the conditional download, validation and hot swap; with no update
|
||||
// URL configured the pass is a no-op, so scheduling it is harmless
|
||||
// on installs that never opted in.
|
||||
type GeoIPCountryJob struct{}
|
||||
|
||||
// NewGeoIPCountryJob creates a geoip country job instance.
|
||||
func NewGeoIPCountryJob() *GeoIPCountryJob {
|
||||
return new(GeoIPCountryJob)
|
||||
}
|
||||
|
||||
// Run executes one update pass.
|
||||
func (j *GeoIPCountryJob) Run() {
|
||||
service.UpdateGeoIPCountry()
|
||||
}
|
||||
@@ -100,27 +100,3 @@ func TestProbeOneLeavesUnactivatedAgentNodeAlone(t *testing.T) {
|
||||
t.Fatalf("unactivated agent node status = %q, want unknown (untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOnePanelNodeStillProbed(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
db := database.GetDB()
|
||||
// No agent bound: the legacy probe path must still run and mark an
|
||||
// unreachable panel node offline.
|
||||
n := &model.Node{
|
||||
Name: "panel-node",
|
||||
Scheme: "http",
|
||||
Address: "127.0.0.1",
|
||||
Port: 1, // nothing listens here; probe fails fast
|
||||
Enable: true,
|
||||
Status: "online",
|
||||
}
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
(&NodeHeartbeatJob{}).probeOne(n)
|
||||
|
||||
if got := nodeStatus(t, n.Id); got != "offline" {
|
||||
t.Fatalf("unreachable panel node status = %q, want offline", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -80,40 +79,13 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
}
|
||||
|
||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
// Agent-managed nodes are heartbeated via the agent token, not the
|
||||
// legacy panel probe. Skip the probe so we don't clobber the
|
||||
// agent-driven online status — but do expire that status when the
|
||||
// agent stops heartbeating, or an uninstalled agent would leave its
|
||||
// node showing online forever.
|
||||
var agentCount int64
|
||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", n.Id).Count(&agentCount).Error
|
||||
if agentCount > 0 {
|
||||
j.expireStaleAgentNode(n)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||
defer cancel()
|
||||
prevStatus := n.Status
|
||||
patch, err := j.nodeService.Probe(ctx, n)
|
||||
if err != nil {
|
||||
patch.Status = "offline"
|
||||
} else {
|
||||
patch.Status = "online"
|
||||
}
|
||||
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
|
||||
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
|
||||
}
|
||||
publishNodeTransition(n, prevStatus, patch)
|
||||
// Learn the nodes this node manages so the panel can surface them as
|
||||
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
|
||||
// be spent. Drop them when the node is unreachable.
|
||||
if patch.Status == "online" {
|
||||
dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||
j.nodeService.RefreshDescendants(dctx, n)
|
||||
dcancel()
|
||||
} else {
|
||||
j.nodeService.ClearDescendants(n.Id)
|
||||
}
|
||||
// Every node is agent-managed: it is heartbeated via the agent
|
||||
// token, never dialed. The job's only duty is to expire the
|
||||
// agent-driven online status when the agent stops heartbeating,
|
||||
// or an uninstalled agent would leave its node showing online
|
||||
// forever. A node with no bound agent never checked in and is
|
||||
// left alone.
|
||||
j.expireStaleAgentNode(n)
|
||||
}
|
||||
|
||||
// agentLastSeenMs returns the newest last_seen (Unix milliseconds, the unit
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// REPRO (V1.0.2 fix 2): agent nodes are push-only — their online clients
|
||||
// arrive via agent traffic reports (InboundService.SetAgentOnlineClients)
|
||||
// and are keyed exactly where the node list looks them up (the service-level
|
||||
// alignment is pinned by TestReproAgentNodeOnlineEmailsInNodeList). But the
|
||||
// traffic sync job's pull loop treats every enabled+online node as a panel:
|
||||
// for an agent node the snapshot fetch can never succeed (address is the
|
||||
// literal placeholder "agent", there is no panel API behind it), and the
|
||||
// fetch-failure branch calls ClearNodeOnlineClients — wiping the agent's
|
||||
// pushed online state on every tick. The heartbeat job already skips
|
||||
// agent-managed nodes for probing; the sync job must not pull them either.
|
||||
func TestSyncOne_AgentNodeKeepsReportedOnlines(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
node := &model.Node{
|
||||
Name: "agent-sync-node",
|
||||
Address: "agent",
|
||||
Port: 2053,
|
||||
Enable: true,
|
||||
Status: "online",
|
||||
}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.AgentNode{
|
||||
TokenHash: "hash-agent-sync-node",
|
||||
Name: "agent-sync-node",
|
||||
NodeID: &node.Id,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create agent binding: %v", err)
|
||||
}
|
||||
// Production form: the agent node actually hosts inbounds (without any
|
||||
// node-bound inbound in the DB, NodeService.GetAll takes its early
|
||||
// return and never fills per-node stats at all).
|
||||
if err := db.Create(&model.Inbound{
|
||||
Tag: "agent-sync-in-1", Enable: true, Port: 46994, Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`, NodeID: &node.Id,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
inboundSvc := service.InboundService{}
|
||||
inboundSvc.ClearNodeOnlineClients(node.Id)
|
||||
t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(node.Id) })
|
||||
|
||||
inboundSvc.SetAgentOnlineClients(node.Id, []string{"agent-online@example.com"})
|
||||
if got := inboundSvc.GetOnlineClients(); len(got) != 1 {
|
||||
t.Fatalf("precondition: online union = %v, want the agent-reported email", got)
|
||||
}
|
||||
|
||||
j := NewNodeTrafficSyncJob()
|
||||
j.syncOne(runtime.NewManager(), node, false)
|
||||
|
||||
byGuid := inboundSvc.GetOnlineClientsByGuid()
|
||||
found := false
|
||||
for _, emails := range byGuid {
|
||||
for _, e := range emails {
|
||||
if e == "agent-online@example.com" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("agent onlines wiped by sync tick: byGuid = %v", byGuid)
|
||||
}
|
||||
|
||||
views, err := (&service.NodeService{}).GetNodeTreeView()
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeTreeView: %v", err)
|
||||
}
|
||||
for _, v := range views {
|
||||
if v.Id == node.Id {
|
||||
if len(v.OnlineEmails) != 1 || v.OnlineEmails[0] != "agent-online@example.com" {
|
||||
t.Fatalf("node view online emails = %v, want [agent-online@example.com]", v.OnlineEmails)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,16 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const (
|
||||
nodeTrafficSyncConcurrency = 8
|
||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||
nodeReconcileTimeout = 30 * time.Second
|
||||
nodeClientIpSyncInterval = 10 * time.Second
|
||||
nodeClientIpSyncTimeout = 6 * time.Second
|
||||
nodeGlobalPushInterval = 30 * time.Second
|
||||
// nodeInboundSpeedWindowMs is the poll window node-inbound speed deltas are
|
||||
// normalized to; it MUST match the dashboard's TRAFFIC_POLL_INTERVAL_S (5s),
|
||||
// the fixed divisor the frontend applies to turn a delta into a rate.
|
||||
@@ -34,20 +23,16 @@ type inboundSample struct {
|
||||
up, down, at int64
|
||||
}
|
||||
|
||||
// NodeTrafficSyncJob is the fleet-wide traffic tick. In the agent era
|
||||
// node traffic and online state arrive via agent reports, so there is
|
||||
// nothing to pull per node; what remains is the fleet-wide duty set:
|
||||
// the client depletion check, pruning the local online sets, deriving
|
||||
// per-node-inbound speeds from the central counters, and the websocket
|
||||
// broadcasts the dashboard consumes.
|
||||
type NodeTrafficSyncJob struct {
|
||||
nodeService service.NodeService
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
xrayService service.XrayService
|
||||
running sync.Mutex
|
||||
structural atomicBool
|
||||
ipSyncMu sync.Mutex
|
||||
lastIpSync int64
|
||||
globalPushMu sync.Mutex
|
||||
lastGlobalPush int64
|
||||
// noGuidIpEndpoint tracks nodes (by id) whose client-IP attribution endpoint
|
||||
// returned 404, so an old-build node is noted once instead of every cycle.
|
||||
noGuidIpEndpoint sync.Map
|
||||
// prevInboundTotals holds the previous poll's cumulative up/down (and the time
|
||||
// the counter last changed) per node inbound tag, so the next poll can derive
|
||||
// a per-inbound speed delta — node inbounds have no local Xray poll. Touched
|
||||
@@ -84,53 +69,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
}
|
||||
defer j.running.Unlock()
|
||||
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := j.nodeService.GetAll()
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: load nodes failed:", err)
|
||||
return
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Decide once per tick whether this run also syncs client IPs, and stamp the
|
||||
// clock before the loop so two back-to-back 5s ticks can't both qualify.
|
||||
doIpSync := false
|
||||
j.ipSyncMu.Lock()
|
||||
if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
|
||||
doIpSync = true
|
||||
j.lastIpSync = now
|
||||
}
|
||||
j.ipSyncMu.Unlock()
|
||||
|
||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var activeMu sync.Mutex
|
||||
var activeEmails []string
|
||||
for _, n := range nodes {
|
||||
if !n.Enable || n.Status != "online" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
n := n
|
||||
common.GoRecover("node-traffic-sync:"+n.Name, func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if emails := j.syncOne(mgr, n, doIpSync); len(emails) > 0 {
|
||||
activeMu.Lock()
|
||||
activeEmails = append(activeEmails, emails...)
|
||||
activeMu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
_, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
|
||||
if err != nil {
|
||||
logger.Warning("node traffic sync: depletion check failed:", err)
|
||||
@@ -139,8 +77,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
j.structural.set()
|
||||
}
|
||||
|
||||
j.maybePushGlobals(mgr, nodes)
|
||||
|
||||
// Prune stale local-online entries (no local active emails or inbound tags
|
||||
// to add here — only the local xray poll feeds those) so a stopped local
|
||||
// xray's clients and inbounds still age out between traffic polls.
|
||||
@@ -157,6 +93,10 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||
// Same snapshot-vs-delta split as the local traffic job: above the
|
||||
// threshold a full snapshot would be dropped by the hub's payload cap, so
|
||||
// send only the rows for clients online on the synced nodes this tick.
|
||||
// Agent reports file online state into the online store rather than
|
||||
// this tick's return, so the delta path keys off an empty active set
|
||||
// exactly as it did while every node was agent-managed.
|
||||
var activeEmails []string
|
||||
snapshot := true
|
||||
if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
|
||||
logger.Warning("node traffic sync: count client traffics failed:", countErr)
|
||||
@@ -284,213 +224,3 @@ func (j *NodeTrafficSyncJob) nodeInboundSpeed() []*xray.Traffic {
|
||||
j.prevInboundTotals = next
|
||||
return deltas
|
||||
}
|
||||
|
||||
// maybePushGlobals broadcasts this panel's aggregated per-client usage to its
|
||||
// online nodes so each node can display the client's cross-panel total and
|
||||
// enforce its quota locally (see InboundService.AcceptGlobalTraffic). Scoped
|
||||
// per node to the clients that node actually hosts, and throttled — the
|
||||
// aggregates only need to reach nodes on a human timescale, not every poll.
|
||||
func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*model.Node) {
|
||||
j.globalPushMu.Lock()
|
||||
now := time.Now().Unix()
|
||||
if now-j.lastGlobalPush < int64(nodeGlobalPushInterval/time.Second) {
|
||||
j.globalPushMu.Unlock()
|
||||
return
|
||||
}
|
||||
j.lastGlobalPush = now
|
||||
j.globalPushMu.Unlock()
|
||||
|
||||
masterGuid, err := j.settingService.GetPanelGuid()
|
||||
if err != nil || masterGuid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range nodes {
|
||||
if !n.Enable || n.Status != "online" {
|
||||
continue
|
||||
}
|
||||
remote, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
traffics, err := j.inboundService.GetNodeClientTraffics(n.Id)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: load globals for %s failed: %v", n.Name, err)
|
||||
continue
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
n, remote, traffics := n, remote, traffics
|
||||
common.GoRecover("node-global-push:"+n.Name, func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
defer cancel()
|
||||
if err := remote.PushGlobalClientTraffics(ctx, masterGuid, traffics); err != nil {
|
||||
// An old-build node without the endpoint answers 404 — not worth a
|
||||
// warning every cycle.
|
||||
if strings.Contains(err.Error(), "HTTP 404") {
|
||||
logger.Debugf("node traffic sync: node %s has no global-traffic endpoint (old build)", n.Name)
|
||||
} else {
|
||||
logger.Warningf("node traffic sync: push globals to %s failed: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// syncOne pulls one node's traffic snapshot and merges it. It returns the
|
||||
// emails online on that node this tick, feeding the delta broadcast above the
|
||||
// snapshot threshold; nil on any failure path.
|
||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
|
||||
// Agent-managed nodes are push-only: their traffic and online state
|
||||
// arrive via agent reports (SetAgentOnlineClients), and there is no
|
||||
// panel API to pull a snapshot from — the node address is the literal
|
||||
// "agent" placeholder. Pulling one always fails, and the failure
|
||||
// branch below clears the node's whole online subtree, wiping the
|
||||
// state the agent just pushed on every 5s tick (the node list then
|
||||
// shows agent nodes permanently dark while panel nodes, whose fetch
|
||||
// succeeds and re-files a fresh tree in the same tick, are fine).
|
||||
// The heartbeat job already treats agent nodes the same way: no
|
||||
// probing, agent-driven state only.
|
||||
if j.nodeService.IsAgentManaged(n.Id) {
|
||||
return nil
|
||||
}
|
||||
rt, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
justPushed := false
|
||||
if n.ConfigDirty {
|
||||
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
|
||||
reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
|
||||
reconcileCancel()
|
||||
if reconcileErr != nil {
|
||||
// The dirty flag stays set so reconcile retries next tick, but traffic
|
||||
// accounting must keep flowing: one rejected inbound used to starve the
|
||||
// whole node's traffic/online sync forever (#5685).
|
||||
logger.Warningf("node traffic sync: reconcile for %s failed, continuing with traffic pull: %v", n.Name, reconcileErr)
|
||||
} else {
|
||||
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
|
||||
logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
|
||||
}
|
||||
j.structural.set()
|
||||
// The snapshot below may still predate the push we just made, so its
|
||||
// lagging lifecycle values must not merge back this tick (#6228).
|
||||
justPushed = true
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
snap, err := rt.FetchTrafficSnapshot(ctx)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
|
||||
j.inboundService.ClearNodeOnlineClients(n.Id)
|
||||
return nil
|
||||
}
|
||||
snap.ManagedAliases = rt.AdoptedInboundAliases()
|
||||
syncCanAdopt := syncCanAdoptInbounds(n, snap.ManagedAliases)
|
||||
service.FilterNodeSnapshot(n, snap)
|
||||
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
|
||||
if !dirty {
|
||||
if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil {
|
||||
logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr)
|
||||
} else if pending {
|
||||
hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
groups, hgErr := rt.FetchHostGroups(hostCtx)
|
||||
hostCancel()
|
||||
if hgErr != nil {
|
||||
logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr)
|
||||
} else {
|
||||
snap.HostGroups = groups
|
||||
}
|
||||
}
|
||||
}
|
||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty, justPushed)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
|
||||
return nil
|
||||
}
|
||||
if changed {
|
||||
j.structural.set()
|
||||
}
|
||||
if !dirty && n.InboundsAdoptedAt == 0 && syncCanAdopt {
|
||||
if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
|
||||
logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
|
||||
}
|
||||
}
|
||||
|
||||
active := make([]string, 0, len(snap.OnlineEmails))
|
||||
active = append(active, snap.OnlineEmails...)
|
||||
for _, emails := range snap.OnlineTree {
|
||||
active = append(active, emails...)
|
||||
}
|
||||
|
||||
if !doIpSync {
|
||||
return active
|
||||
}
|
||||
|
||||
ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout)
|
||||
defer ipCancel()
|
||||
|
||||
nodeIps, err := rt.FetchAllClientIps(ipCtx)
|
||||
if err == nil && len(nodeIps) > 0 {
|
||||
if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
|
||||
logger.Warningf("node traffic sync: merge client ips from %s failed: %v", n.Name, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
logger.Warningf("node traffic sync: fetch client ips from %s failed: %v", n.Name, err)
|
||||
}
|
||||
|
||||
masterIps, err := j.inboundService.GetAllInboundClientIps()
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
|
||||
return active
|
||||
}
|
||||
if len(masterIps) > 0 {
|
||||
if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
|
||||
logger.Warningf("node traffic sync: push client ips to %s failed: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-node IP attribution: pull the node's guid-keyed subtree (its own
|
||||
// observations plus any descendants) so the master can tell which node each
|
||||
// IP is on. Old nodes without the endpoint return HTTP 404 every cycle — note
|
||||
// it once per node (re-armed on recovery) instead of flooding the log.
|
||||
if guidTrees, err := rt.FetchClientIpsByGuid(ipCtx); err != nil {
|
||||
if strings.Contains(err.Error(), "HTTP 404") {
|
||||
if _, seen := j.noGuidIpEndpoint.LoadOrStore(n.Id, true); !seen {
|
||||
logger.Debugf("node traffic sync: node %s has no client-IP attribution endpoint (old build)", n.Name)
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("node traffic sync: fetch client ip attribution from %s failed: %v", n.Name, err)
|
||||
}
|
||||
} else {
|
||||
j.noGuidIpEndpoint.Delete(n.Id)
|
||||
if len(guidTrees) > 0 {
|
||||
if err := j.inboundService.MergeClientIpsByGuid(n, guidTrees); err != nil {
|
||||
logger.Warningf("node traffic sync: merge client ip attribution from %s failed: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
// Whether this sync can perform the "first clean adoption" that
|
||||
// InboundsAdoptedAt records (#6283).
|
||||
func syncCanAdoptInbounds(n *model.Node, adoptedAliases []string) bool {
|
||||
if n == nil || n.InboundSyncMode != "selected" {
|
||||
return true
|
||||
}
|
||||
return len(n.InboundTags) > 0 || len(adoptedAliases) > 0
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package job
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestAtomicBool_DefaultIsFalse(t *testing.T) {
|
||||
@@ -69,44 +67,3 @@ func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
|
||||
t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression (#6283): a node onboarded in selected mode with an empty tag
|
||||
// list empties its snapshot via FilterNodeSnapshot before the merge sees it,
|
||||
// so that sync adopts nothing and must not stamp InboundsAdoptedAt.
|
||||
func TestSyncCanAdoptInbounds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
node *model.Node
|
||||
aliases []string
|
||||
expected bool
|
||||
}{
|
||||
{"all mode always adopts", &model.Node{InboundSyncMode: "all"}, nil, true},
|
||||
{
|
||||
"selected with tags adopts",
|
||||
&model.Node{InboundSyncMode: "selected", InboundTags: []string{"in-443-tcp"}},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"selected empty with adopted alias adopts",
|
||||
&model.Node{InboundSyncMode: "selected"},
|
||||
[]string{"in-443-tcp"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
// The reported bug: registering in selected mode and choosing
|
||||
// tags afterwards stamped adoption while adopting nothing.
|
||||
"selected empty with no aliases adopts nothing",
|
||||
&model.Node{InboundSyncMode: "selected"},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := syncCanAdoptInbounds(c.node, c.aliases); got != c.expected {
|
||||
t.Fatalf("syncCanAdoptInbounds(%+v, %v) = %v, want %v", c.node, c.aliases, got, c.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// ReapSyncOrphansJob hard-deletes the clients the node-snapshot sweep only
|
||||
// soft-orphaned, once enough clean merges have agreed they are really gone.
|
||||
type ReapSyncOrphansJob struct {
|
||||
clientService service.ClientService
|
||||
}
|
||||
|
||||
func NewReapSyncOrphansJob() *ReapSyncOrphansJob {
|
||||
return new(ReapSyncOrphansJob)
|
||||
}
|
||||
|
||||
func (j *ReapSyncOrphansJob) Run() {
|
||||
reaped, err := j.clientService.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
logger.Warning("reap sync orphans failed:", err)
|
||||
return
|
||||
}
|
||||
if reaped > 0 {
|
||||
logger.Infof("reap sync orphans: removed %d client(s)", reaped)
|
||||
}
|
||||
}
|
||||
+216
-38
@@ -7,7 +7,9 @@ import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
@@ -30,19 +32,57 @@ const (
|
||||
Web I18nType = "web" // Web interface type
|
||||
)
|
||||
|
||||
// defaultLanguageTag is the bundle's fallback language — the one
|
||||
// language parsed eagerly at startup.
|
||||
const defaultLanguageTag = "en-US"
|
||||
|
||||
// i18nSource is one translation file held as raw bytes. Parsing is
|
||||
// what costs memory (ITEM-P3 baseline: all 13 language files parsed
|
||||
// ≈ 10.2 MB of live heap, ~2/3 of the master's total), while the raw
|
||||
// JSON is an order of magnitude smaller — so sources stay resident
|
||||
// unparsed and each language is parsed on first use and cached
|
||||
// (ITEM-P3 M1).
|
||||
type i18nSource struct {
|
||||
tag string // language tag from the file name, e.g. "zh-CN"
|
||||
path string // path handed to ParseMessageFileBytes, as before
|
||||
data []byte
|
||||
}
|
||||
|
||||
var (
|
||||
// i18nMu serializes lazy parses against localizations: parsing
|
||||
// takes the write lock, Localizer creation and Localize calls
|
||||
// take the read lock, so a first-use parse can never race a
|
||||
// lookup in another goroutine.
|
||||
i18nMu sync.RWMutex
|
||||
i18nSources []i18nSource
|
||||
i18nParsed map[string]bool
|
||||
)
|
||||
|
||||
// SettingService interface defines methods for accessing locale settings.
|
||||
type SettingService interface {
|
||||
GetTgLang() (string, error)
|
||||
}
|
||||
|
||||
// InitLocalizer initializes the internationalization system with embedded translation files.
|
||||
// Only the fallback language (and, via initTGBotLocalizer, the bot's
|
||||
// configured language) is parsed here; every other language is
|
||||
// indexed as raw bytes and parsed lazily on first use.
|
||||
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
||||
i18nMu.Lock()
|
||||
// set default bundle to English
|
||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||
i18nBundle = i18n.NewBundle(language.MustParse(defaultLanguageTag))
|
||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
i18nSources = nil
|
||||
i18nParsed = map[string]bool{}
|
||||
|
||||
// parse files
|
||||
if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil {
|
||||
// index the translation files, then parse the fallback language
|
||||
if err := indexTranslationFiles(i18nFS); err != nil {
|
||||
i18nMu.Unlock()
|
||||
return err
|
||||
}
|
||||
err := ensureLanguageLocked(defaultLanguageTag)
|
||||
i18nMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -93,10 +133,12 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
i18nMu.RLock()
|
||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
TemplateData: templateData,
|
||||
})
|
||||
i18nMu.RUnlock()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to localize message: %v", err)
|
||||
return ""
|
||||
@@ -112,6 +154,16 @@ func initTGBotLocalizer(settingService SettingService) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// The bot localizer is created once, here, so its language is a
|
||||
// startup language: parse it now (its first use). A parse failure
|
||||
// fails startup, exactly as the old parse-everything init did for
|
||||
// every language.
|
||||
if err := ensureLanguage(botLang); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i18nMu.RLock()
|
||||
defer i18nMu.RUnlock()
|
||||
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
|
||||
return nil
|
||||
}
|
||||
@@ -123,14 +175,8 @@ func initTGBotLocalizer(settingService SettingService) error {
|
||||
func LocalizerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Ensure bundle is initialized so creating a Localizer won't panic
|
||||
if i18nBundle == nil {
|
||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
// Try lazy-load from disk when running sub server without InitLocalizer
|
||||
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
|
||||
logger.Warning("i18n lazy load failed:", err)
|
||||
}
|
||||
}
|
||||
ensureBundle()
|
||||
|
||||
var lang string
|
||||
|
||||
if cookie, err := c.Request.Cookie("lang"); err == nil {
|
||||
@@ -139,7 +185,16 @@ func LocalizerMiddleware() gin.HandlerFunc {
|
||||
lang = c.GetHeader("Accept-Language")
|
||||
}
|
||||
|
||||
// First use of a language parses it (cached afterwards). A
|
||||
// parse failure only degrades this request to the fallback
|
||||
// language; it must not fail the request.
|
||||
if err := ensureLanguage(lang); err != nil {
|
||||
logger.Warning("i18n lazy load failed:", err)
|
||||
}
|
||||
|
||||
i18nMu.RLock()
|
||||
LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
|
||||
i18nMu.RUnlock()
|
||||
|
||||
c.Set("localizer", LocalizerWeb)
|
||||
c.Set("I18n", I18n)
|
||||
@@ -147,28 +202,127 @@ func LocalizerMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// loadTranslationsFromDisk attempts to load translation files from "internal/web/translation" using the local filesystem.
|
||||
func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
|
||||
root := os.DirFS("internal/web")
|
||||
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := fs.ReadFile(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = bundle.ParseMessageFileBytes(data, path)
|
||||
return err
|
||||
})
|
||||
// ensureBundle covers processes that serve web requests without
|
||||
// InitLocalizer having run (the sub server's historical lazy path):
|
||||
// it creates the bundle and indexes the translation files from disk.
|
||||
func ensureBundle() {
|
||||
i18nMu.RLock()
|
||||
ready := i18nBundle != nil
|
||||
i18nMu.RUnlock()
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
|
||||
i18nMu.Lock()
|
||||
defer i18nMu.Unlock()
|
||||
if i18nBundle != nil {
|
||||
return
|
||||
}
|
||||
i18nBundle = i18n.NewBundle(language.MustParse(defaultLanguageTag))
|
||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
i18nParsed = map[string]bool{}
|
||||
if err := loadTranslationsFromDisk(); err != nil {
|
||||
logger.Warning("i18n lazy load failed:", err)
|
||||
return
|
||||
}
|
||||
if err := ensureLanguageLocked(defaultLanguageTag); err != nil {
|
||||
logger.Warning("i18n lazy load failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTranslationFiles parses embedded translation files and adds them to the i18n bundle.
|
||||
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
||||
err := fs.WalkDir(i18nFS, "translation",
|
||||
// ensureLanguage parses the translation file best matching the
|
||||
// preference (a bare tag like "zh-CN", or an Accept-Language header
|
||||
// value) unless it is already parsed, and always makes sure the
|
||||
// fallback language is present. Concurrent first uses are safe.
|
||||
func ensureLanguage(pref string) error {
|
||||
i18nMu.Lock()
|
||||
defer i18nMu.Unlock()
|
||||
return ensureLanguageLocked(pref)
|
||||
}
|
||||
|
||||
// ensureLanguageLocked is ensureLanguage with i18nMu write-held.
|
||||
func ensureLanguageLocked(pref string) error {
|
||||
if err := parseLanguageLocked(defaultLanguageTag); err != nil {
|
||||
return err
|
||||
}
|
||||
tag := matchLanguageTag(pref)
|
||||
if tag == "" || tag == defaultLanguageTag {
|
||||
return nil
|
||||
}
|
||||
return parseLanguageLocked(tag)
|
||||
}
|
||||
|
||||
// matchLanguageTag resolves a preference to one of the indexed
|
||||
// source tags, or "" when nothing plausible matches — an unsupported
|
||||
// language must not trigger a pointless parse, it just falls back.
|
||||
func matchLanguageTag(pref string) string {
|
||||
// An Accept-Language header is a preference list with optional
|
||||
// quality values; the first entry is the most preferred.
|
||||
if i := strings.IndexAny(pref, ",;"); i >= 0 {
|
||||
pref = pref[:i]
|
||||
}
|
||||
pref = strings.TrimSpace(pref)
|
||||
if pref == "" {
|
||||
return ""
|
||||
}
|
||||
for _, src := range i18nSources {
|
||||
if strings.EqualFold(src.tag, pref) {
|
||||
return src.tag
|
||||
}
|
||||
}
|
||||
want, err := language.Parse(pref)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
tags := make([]language.Tag, 0, len(i18nSources))
|
||||
raws := make([]string, 0, len(i18nSources))
|
||||
for _, src := range i18nSources {
|
||||
if t, err := language.Parse(src.tag); err == nil {
|
||||
tags = append(tags, t)
|
||||
raws = append(raws, src.tag)
|
||||
}
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
_, idx, confidence := language.NewMatcher(tags).Match(want)
|
||||
if confidence == language.No {
|
||||
return ""
|
||||
}
|
||||
return raws[idx]
|
||||
}
|
||||
|
||||
// parseLanguageLocked parses one indexed language into the bundle,
|
||||
// once. Unknown tags are a no-op (lookups fall back), as before.
|
||||
// Caller holds i18nMu (write).
|
||||
func parseLanguageLocked(tag string) error {
|
||||
if i18nParsed[tag] {
|
||||
return nil
|
||||
}
|
||||
for _, src := range i18nSources {
|
||||
if src.tag == tag {
|
||||
if _, err := i18nBundle.ParseMessageFileBytes(src.data, src.path); err != nil {
|
||||
return err
|
||||
}
|
||||
i18nParsed[tag] = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// languageTagFromPath extracts the language tag from a translation
|
||||
// file path ("translation/zh-CN.json" -> "zh-CN"), the same name
|
||||
// ParseMessageFileBytes derives the language from.
|
||||
func languageTagFromPath(p string) string {
|
||||
base := path.Base(p)
|
||||
return strings.TrimSuffix(base, path.Ext(base))
|
||||
}
|
||||
|
||||
// indexTranslationFiles indexes the embedded translation files as
|
||||
// raw bytes without parsing them (ITEM-P3 M1). Caller holds i18nMu.
|
||||
func indexTranslationFiles(i18nFS embed.FS) error {
|
||||
return fs.WalkDir(i18nFS, "translation",
|
||||
func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -183,12 +337,36 @@ func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = i18nBundle.ParseMessageFileBytes(data, path)
|
||||
return err
|
||||
i18nSources = append(i18nSources, i18nSource{
|
||||
tag: languageTagFromPath(path),
|
||||
path: path,
|
||||
data: data,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadTranslationsFromDisk indexes translation files from
|
||||
// "internal/web/translation" using the local filesystem, as raw
|
||||
// bytes (parsing stays lazy). Caller holds i18nMu.
|
||||
func loadTranslationsFromDisk() error {
|
||||
root := os.DirFS("internal/web")
|
||||
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := fs.ReadFile(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i18nSources = append(i18nSources, i18nSource{
|
||||
tag: languageTagFromPath(path),
|
||||
path: path,
|
||||
data: data,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
)
|
||||
|
||||
// Test fixtures mirror the production layout (an embed FS whose
|
||||
// root holds "translation/<tag>.json") with three small languages.
|
||||
// They live under this package's own translation/ directory and are
|
||||
// used only by these tests — the panel embeds internal/web's files.
|
||||
|
||||
//go:embed translation
|
||||
var testI18nFS embed.FS
|
||||
|
||||
type fakeSettingService struct{ lang string }
|
||||
|
||||
func (f fakeSettingService) GetTgLang() (string, error) { return f.lang, nil }
|
||||
|
||||
func parsedTags() map[string]bool {
|
||||
i18nMu.RLock()
|
||||
defer i18nMu.RUnlock()
|
||||
out := make(map[string]bool, len(i18nParsed))
|
||||
for k, v := range i18nParsed {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localize mirrors how production localizers are built (language
|
||||
// ensured first, then NewLocalizer) and returns the raw library
|
||||
// result: go-i18n answers a key missing in the requested language
|
||||
// with the default language's text AND a MessageNotFoundErr, so
|
||||
// callers here assert both halves of that contract.
|
||||
func localize(t *testing.T, lang, key string, templateData map[string]any) (string, error) {
|
||||
t.Helper()
|
||||
if err := ensureLanguage(lang); err != nil {
|
||||
t.Fatalf("ensureLanguage(%q): %v", lang, err)
|
||||
}
|
||||
i18nMu.RLock()
|
||||
loc := i18n.NewLocalizer(i18nBundle, lang)
|
||||
i18nMu.RUnlock()
|
||||
return loc.Localize(&i18n.LocalizeConfig{MessageID: key, TemplateData: templateData})
|
||||
}
|
||||
|
||||
func localizeOK(t *testing.T, lang, key string, templateData map[string]any) string {
|
||||
t.Helper()
|
||||
msg, err := localize(t, lang, key, templateData)
|
||||
if err != nil {
|
||||
t.Fatalf("Localize(%q, %q): %v", lang, key, err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// ITEM-P3 M1: startup parses only the fallback (+ the bot's
|
||||
// language); other languages parse on first use, then serve from
|
||||
// cache; unsupported languages parse nothing and fall back.
|
||||
func TestLazyLanguageParsing(t *testing.T) {
|
||||
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "en-US"}); err != nil {
|
||||
t.Fatalf("InitLocalizer: %v", err)
|
||||
}
|
||||
parsed := parsedTags()
|
||||
if !parsed["en-US"] {
|
||||
t.Fatalf("fallback language not parsed at init: %v", parsed)
|
||||
}
|
||||
if parsed["zh-CN"] || parsed["fa-IR"] {
|
||||
t.Fatalf("non-startup languages parsed at init: %v", parsed)
|
||||
}
|
||||
|
||||
if got := localizeOK(t, "zh-CN", "hello", nil); got != "你好" {
|
||||
t.Fatalf("zh-CN hello = %q, want 你好", got)
|
||||
}
|
||||
if got := localizeOK(t, "zh-CN", "greet", map[string]any{"Name": "World"}); got != "你好 World" {
|
||||
t.Fatalf("zh-CN greet = %q", got)
|
||||
}
|
||||
parsed = parsedTags()
|
||||
if !parsed["zh-CN"] || parsed["fa-IR"] {
|
||||
t.Fatalf("after zh-CN use, parsed = %v", parsed)
|
||||
}
|
||||
|
||||
// A key missing in the requested language follows go-i18n's
|
||||
// fallback contract: default-language text plus a
|
||||
// MessageNotFoundErr — and the production I18n() wrapper maps
|
||||
// that error to "", exactly as it did when every language was
|
||||
// parsed eagerly.
|
||||
msg, err := localize(t, "zh-CN", "only_en", nil)
|
||||
if msg != "Only in English" {
|
||||
t.Fatalf("fallback text = %q, want English text", msg)
|
||||
}
|
||||
var notFound *i18n.MessageNotFoundErr
|
||||
if !errors.As(err, ¬Found) {
|
||||
t.Fatalf("fallback err = %v, want MessageNotFoundErr", err)
|
||||
}
|
||||
i18nMu.RLock()
|
||||
LocalizerWeb = i18n.NewLocalizer(i18nBundle, "zh-CN")
|
||||
i18nMu.RUnlock()
|
||||
if got := I18n(Web, "only_en"); got != "" {
|
||||
t.Fatalf("I18n fallback = %q, want \"\" (wrapper error contract)", got)
|
||||
}
|
||||
if got := I18n(Web, "hello"); got != "你好" {
|
||||
t.Fatalf("I18n zh-CN hello = %q, want 你好", got)
|
||||
}
|
||||
|
||||
// A bare language preference resolves to the regional file.
|
||||
if got := localizeOK(t, "fa", "hello", nil); got != "سلام" {
|
||||
t.Fatalf("fa hello = %q, want سلام", got)
|
||||
}
|
||||
if !parsedTags()["fa-IR"] {
|
||||
t.Fatal("fa-IR not parsed after fa preference")
|
||||
}
|
||||
|
||||
// An Accept-Language header value resolves to its first entry.
|
||||
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "en-US"}); err != nil {
|
||||
t.Fatalf("re-init: %v", err)
|
||||
}
|
||||
if err := ensureLanguage("zh-CN,zh;q=0.9,en;q=0.8"); err != nil {
|
||||
t.Fatalf("ensureLanguage(header): %v", err)
|
||||
}
|
||||
if !parsedTags()["zh-CN"] {
|
||||
t.Fatal("zh-CN not parsed from Accept-Language header")
|
||||
}
|
||||
|
||||
// An unsupported language parses nothing and still localizes
|
||||
// (in the fallback language, matched directly, so no error).
|
||||
before := parsedTags()
|
||||
if got := localizeOK(t, "de-DE", "hello", nil); got != "Hello" {
|
||||
t.Fatalf("de-DE hello = %q, want fallback Hello", got)
|
||||
}
|
||||
after := parsedTags()
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("unsupported language triggered a parse: before %v after %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// The bot's configured language is a startup language: its
|
||||
// localizer must work immediately after init.
|
||||
func TestBotLanguageParsedAtInit(t *testing.T) {
|
||||
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "fa-IR"}); err != nil {
|
||||
t.Fatalf("InitLocalizer: %v", err)
|
||||
}
|
||||
if !parsedTags()["fa-IR"] {
|
||||
t.Fatal("bot language not parsed at init")
|
||||
}
|
||||
if got := I18n(Bot, "hello"); got != "سلام" {
|
||||
t.Fatalf("bot hello = %q, want سلام", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent first use of several languages: parses and lookups
|
||||
// must not race (run with -race) and every lookup must succeed.
|
||||
func TestConcurrentFirstUse(t *testing.T) {
|
||||
if err := InitLocalizer(testI18nFS, fakeSettingService{lang: "en-US"}); err != nil {
|
||||
t.Fatalf("InitLocalizer: %v", err)
|
||||
}
|
||||
langs := []string{"zh-CN", "fa-IR", "en-US", "zh", "fa", "de-DE"}
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 256)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, lang := range langs {
|
||||
if err := ensureLanguage(lang); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
i18nMu.RLock()
|
||||
loc := i18n.NewLocalizer(i18nBundle, lang)
|
||||
i18nMu.RUnlock()
|
||||
if _, err := loc.Localize(&i18n.LocalizeConfig{MessageID: "hello"}); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent first use: %v", err)
|
||||
}
|
||||
parsed := parsedTags()
|
||||
if !parsed["zh-CN"] || !parsed["fa-IR"] || !parsed["en-US"] {
|
||||
t.Fatalf("parsed after concurrent use = %v", parsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"greet":"Hello {{.Name}}","hello":"Hello","only_en":"Only in English"}
|
||||
@@ -0,0 +1 @@
|
||||
{"greet":"سلام {{.Name}}","hello":"سلام"}
|
||||
@@ -0,0 +1 @@
|
||||
{"greet":"你好 {{.Name}}","hello":"你好"}
|
||||
+19
-136
@@ -1,37 +1,32 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
type NodeEgressResolver interface {
|
||||
NodeEgressProxyURL(nodeID int) string
|
||||
}
|
||||
|
||||
// Manager dispatches inbound/client writes to a Runtime. In the lite
|
||||
// master every node is agent-managed and the master runs no local core,
|
||||
// so the only production runtime is the no-op Local: writes land in the
|
||||
// database and agent nodes converge by pulling their config. The
|
||||
// override seams remain for tests that exercise the dispatch paths with
|
||||
// a stub runtime.
|
||||
type Manager struct {
|
||||
local Runtime
|
||||
|
||||
mu sync.RWMutex
|
||||
remotes map[int]*Remote
|
||||
overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub
|
||||
localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub
|
||||
egressResolver NodeEgressResolver
|
||||
mu sync.RWMutex
|
||||
overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub
|
||||
localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
local: NewLocal(),
|
||||
remotes: make(map[int]*Remote),
|
||||
local: NewLocal(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of building a
|
||||
// real Remote. Test seam for exercising node-dispatch paths without a network
|
||||
// node; pass nil rt to clear.
|
||||
// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of the
|
||||
// local runtime. Test seam for exercising node-dispatch paths without a
|
||||
// network node; pass nil rt to clear.
|
||||
func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -45,144 +40,32 @@ func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
|
||||
m.overrides[nodeID] = rt
|
||||
}
|
||||
|
||||
// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the real
|
||||
// local runtime. Test seam for exercising the local dispatch path (MTProto
|
||||
// sidecar, local Xray) without a running child process; pass nil rt to clear.
|
||||
// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the
|
||||
// real local runtime. Test seam for exercising the local dispatch path
|
||||
// without a running child process; pass nil rt to clear.
|
||||
func (m *Manager) SetLocalRuntimeOverride(rt Runtime) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.localOverride = rt
|
||||
}
|
||||
|
||||
func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.egressResolver = r
|
||||
}
|
||||
|
||||
func (m *Manager) NodeEgressProxyURL(nodeID int) string {
|
||||
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.egressResolver == nil {
|
||||
return ""
|
||||
}
|
||||
return m.egressResolver.NodeEgressProxyURL(nodeID)
|
||||
}
|
||||
|
||||
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
|
||||
if nodeID == nil {
|
||||
m.mu.RLock()
|
||||
if m.localOverride != nil {
|
||||
rt := m.localOverride
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
return m.localOverride, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
return m.local, nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
if rt, ok := m.overrides[*nodeID]; ok {
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
}
|
||||
if rt, ok := m.remotes[*nodeID]; ok {
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if rt, ok := m.remotes[*nodeID]; ok {
|
||||
return rt, nil
|
||||
}
|
||||
n, err := loadNode(*nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !n.Enable {
|
||||
return nil, errors.New("node " + n.Name + " is disabled")
|
||||
}
|
||||
rt := NewRemote(n, m.egressResolver)
|
||||
m.remotes[*nodeID] = rt
|
||||
return rt, nil
|
||||
return m.local, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Local() Runtime { return m.local }
|
||||
|
||||
func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
|
||||
if node == nil {
|
||||
return nil, errors.New("node is nil")
|
||||
}
|
||||
m.mu.RLock()
|
||||
if rt, ok := m.remotes[node.Id]; ok {
|
||||
if sameRemoteIdentity(rt.node, node) {
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
} else {
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if rt, ok := m.remotes[node.Id]; ok {
|
||||
if sameRemoteIdentity(rt.node, node) {
|
||||
return rt, nil
|
||||
}
|
||||
} else {
|
||||
rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
|
||||
m.remotes[node.Id] = rt
|
||||
return rt, nil
|
||||
}
|
||||
rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
|
||||
m.remotes[node.Id] = rt
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
func cloneRemoteNode(n *model.Node) *model.Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *n
|
||||
if n.InboundTags != nil {
|
||||
clone.InboundTags = append([]string(nil), n.InboundTags...)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func sameRemoteIdentity(a, b *model.Node) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return a.Id == b.Id &&
|
||||
a.Scheme == b.Scheme &&
|
||||
a.Address == b.Address &&
|
||||
a.Port == b.Port &&
|
||||
a.BasePath == b.BasePath &&
|
||||
a.ApiToken == b.ApiToken &&
|
||||
a.AllowPrivateAddress == b.AllowPrivateAddress &&
|
||||
a.TlsVerifyMode == b.TlsVerifyMode &&
|
||||
a.PinnedCertSha256 == b.PinnedCertSha256 &&
|
||||
a.OutboundTag == b.OutboundTag
|
||||
}
|
||||
|
||||
func (m *Manager) InvalidateNode(nodeID int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.remotes, nodeID)
|
||||
}
|
||||
|
||||
func loadNode(id int) (*model.Node, error) {
|
||||
db := database.GetDB()
|
||||
n := &model.Node{}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var (
|
||||
managerMu sync.RWMutex
|
||||
manager *Manager
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestManagerRemoteForRefreshesChangedCredential(t *testing.T) {
|
||||
m := NewManager()
|
||||
first, err := m.RemoteFor(&model.Node{
|
||||
Id: 1,
|
||||
Name: "node",
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "old-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first RemoteFor: %v", err)
|
||||
}
|
||||
second, err := m.RemoteFor(&model.Node{
|
||||
Id: 1,
|
||||
Name: "node",
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "new-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second RemoteFor: %v", err)
|
||||
}
|
||||
if second == first {
|
||||
t.Fatal("RemoteFor reused stale Remote after ApiToken changed")
|
||||
}
|
||||
if got := second.node.ApiToken; got != "new-token" {
|
||||
t.Fatalf("cached Remote token = %q, want new-token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRemoteForIdentityFields(t *testing.T) {
|
||||
base := model.Node{
|
||||
Id: 7,
|
||||
Name: "node-a",
|
||||
Remark: "old remark",
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "token",
|
||||
AllowPrivateAddress: true,
|
||||
TlsVerifyMode: "pin",
|
||||
PinnedCertSha256: "sha",
|
||||
OutboundTag: "warp",
|
||||
Status: "online",
|
||||
InboundCount: 1,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*model.Node)
|
||||
refresh bool
|
||||
}{
|
||||
{"same", func(*model.Node) {}, false},
|
||||
{"name does not churn", func(n *model.Node) { n.Name = "renamed" }, false},
|
||||
{"remark does not churn", func(n *model.Node) { n.Remark = "new remark" }, false},
|
||||
{"status does not churn", func(n *model.Node) { n.Status = "offline" }, false},
|
||||
{"metrics do not churn", func(n *model.Node) { n.InboundCount = 99 }, false},
|
||||
{"scheme", func(n *model.Node) { n.Scheme = "http" }, true},
|
||||
{"address", func(n *model.Node) { n.Address = "other.example.com" }, true},
|
||||
{"port", func(n *model.Node) { n.Port = 8443 }, true},
|
||||
{"base path", func(n *model.Node) { n.BasePath = "/x/" }, true},
|
||||
{"api token", func(n *model.Node) { n.ApiToken = "next" }, true},
|
||||
{"allow private", func(n *model.Node) { n.AllowPrivateAddress = false }, true},
|
||||
{"tls verify mode", func(n *model.Node) { n.TlsVerifyMode = "skip" }, true},
|
||||
{"pin", func(n *model.Node) { n.PinnedCertSha256 = "other" }, true},
|
||||
{"outbound tag", func(n *model.Node) { n.OutboundTag = "direct" }, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := NewManager()
|
||||
firstNode := base
|
||||
first, err := m.RemoteFor(&firstNode)
|
||||
if err != nil {
|
||||
t.Fatalf("first RemoteFor: %v", err)
|
||||
}
|
||||
|
||||
nextNode := base
|
||||
tc.mutate(&nextNode)
|
||||
second, err := m.RemoteFor(&nextNode)
|
||||
if err != nil {
|
||||
t.Fatalf("second RemoteFor: %v", err)
|
||||
}
|
||||
if gotRefresh := second != first; gotRefresh != tc.refresh {
|
||||
t.Fatalf("refresh = %v, want %v", gotRefresh, tc.refresh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRemoteForClonesInputNode(t *testing.T) {
|
||||
m := NewManager()
|
||||
n := &model.Node{
|
||||
Id: 9,
|
||||
Scheme: "https",
|
||||
Address: "node.example.com",
|
||||
Port: 2053,
|
||||
BasePath: "/",
|
||||
ApiToken: "original",
|
||||
}
|
||||
rt, err := m.RemoteFor(n)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteFor: %v", err)
|
||||
}
|
||||
n.ApiToken = "mutated-after-cache"
|
||||
if got := rt.node.ApiToken; got != "original" {
|
||||
t.Fatalf("cached Remote observed caller mutation: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestReconcileInbound_SkipsUnchanged proves the delta-skip: a second reconcile
|
||||
// of an unchanged inbound that the node still reports sends no push, while a
|
||||
// content change or an absent-on-node inbound forces a fresh push.
|
||||
func TestReconcileInbound_SkipsUnchanged(t *testing.T) {
|
||||
var pushes atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/panel/api/inbounds/update/") {
|
||||
pushes.Add(1)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-1", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
// Pre-seed the tag→id cache so resolveRemoteID needs no network round-trip.
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
// First reconcile: node doesn't report it yet → must push and record the fp.
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
||||
t.Fatalf("first reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 1 {
|
||||
t.Fatalf("after first reconcile pushes=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Second reconcile: unchanged and present on node → skip.
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed {
|
||||
t.Fatalf("second reconcile: pushed=%v err=%v, want skip", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 1 {
|
||||
t.Fatalf("unchanged reconcile pushed again: pushes=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Content change → push again even though it's present on node.
|
||||
ib.Settings = `{"clients":[{"email":"a@x"}]}`
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("changed reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 2 {
|
||||
t.Fatalf("changed reconcile pushes=%d, want 2", got)
|
||||
}
|
||||
|
||||
// Absent on node (e.g. node restarted/lost it) → re-push even if fp matches.
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
||||
t.Fatalf("absent-on-node reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if got := pushes.Load(); got != 3 {
|
||||
t.Fatalf("absent-on-node reconcile pushes=%d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
type nodeCallCounts struct {
|
||||
adds atomic.Int32
|
||||
inboundUpdates atomic.Int32
|
||||
clientMutations atomic.Int32
|
||||
}
|
||||
|
||||
func newCountingNodeServer(t *testing.T, clientsResp string) (*httptest.Server, *nodeCallCounts) {
|
||||
t.Helper()
|
||||
counts := &nodeCallCounts{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/panel/api/inbounds/add"):
|
||||
counts.adds.Add(1)
|
||||
case strings.Contains(r.URL.Path, "/panel/api/inbounds/update/"):
|
||||
counts.inboundUpdates.Add(1)
|
||||
case strings.Contains(r.URL.Path, "/panel/api/clients/"):
|
||||
counts.clientMutations.Add(1)
|
||||
_, _ = w.Write([]byte(clientsResp))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, counts
|
||||
}
|
||||
|
||||
func perClientMutationCases(client model.Client) []struct {
|
||||
name string
|
||||
run func(*Remote, *model.Inbound) error
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
run func(*Remote, *model.Inbound) error
|
||||
}{
|
||||
{"add", func(r *Remote, ib *model.Inbound) error {
|
||||
return r.AddClient(context.Background(), ib, client)
|
||||
}},
|
||||
{"delete", func(r *Remote, ib *model.Inbound) error {
|
||||
return r.DeleteUser(context.Background(), ib, client.Email)
|
||||
}},
|
||||
{"update", func(r *Remote, ib *model.Inbound) error {
|
||||
return r.UpdateUser(context.Background(), ib, client.Email, client)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPerClientMutationsAloneDoNotSeedReconcileFingerprint: a per-client RPC
|
||||
// only proves one client's slice converged, so without an explicit advance the
|
||||
// dirty-reconcile backup must still send the full inbound.
|
||||
func TestPerClientMutationsAloneDoNotSeedReconcileFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
client := model.Client{ID: "11111111-1111-1111-1111-111111111111", Email: "a@x", SubID: "s", Enable: true}
|
||||
for _, tt := range perClientMutationCases(client) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
counts.inboundUpdates.Store(0)
|
||||
counts.clientMutations.Store(0)
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-" + tt.name, Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"id":"11111111-1111-1111-1111-111111111111","email":"a@x","subId":"s","enable":true}]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
if err := tt.run(r, ib); err != nil {
|
||||
t.Fatalf("%s client mutation: %v", tt.name, err)
|
||||
}
|
||||
if got := counts.clientMutations.Load(); got != 1 {
|
||||
t.Fatalf("%s client mutation requests=%d, want 1", tt.name, got)
|
||||
}
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("%s reconcile after unadvanced mutation: pushed=%v err=%v, want full push", tt.name, pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 1 {
|
||||
t.Fatalf("%s reconcile sent %d full inbound updates, want 1", tt.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvancePushedInboundEnablesReconcileSkip: when the node provably held the
|
||||
// pre-edit payload and every per-client push succeeded, advancing the
|
||||
// fingerprint lets the next reconcile skip the redundant full push.
|
||||
func TestAdvancePushedInboundEnablesReconcileSkip(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
client := model.Client{ID: "11111111-1111-1111-1111-111111111111", Email: "a@x", SubID: "s", Enable: true}
|
||||
prevByOp := map[string]string{
|
||||
"add": `{"clients":[]}`,
|
||||
"delete": `{"clients":[{"email":"a@x","enable":true}]}`,
|
||||
"update": `{"clients":[{"email":"a@x","enable":false}]}`,
|
||||
}
|
||||
newByOp := map[string]string{
|
||||
"add": `{"clients":[{"email":"a@x","enable":true}]}`,
|
||||
"delete": `{"clients":[]}`,
|
||||
"update": `{"clients":[{"email":"a@x","enable":true}]}`,
|
||||
}
|
||||
for _, tt := range perClientMutationCases(client) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
counts.inboundUpdates.Store(0)
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
prevIb := &model.Inbound{Tag: "in-adv-" + tt.name, Protocol: model.VLESS, Port: 443, Settings: prevByOp[tt.name]}
|
||||
ib := &model.Inbound{Tag: prevIb.Tag, Protocol: model.VLESS, Port: 443, Settings: newByOp[tt.name]}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
r.recordPushedInbound(prevIb)
|
||||
|
||||
if err := tt.run(r, ib); err != nil {
|
||||
t.Fatalf("%s client mutation: %v", tt.name, err)
|
||||
}
|
||||
r.AdvancePushedInbound(prevIb, ib)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed {
|
||||
t.Fatalf("%s reconcile after advance: pushed=%v err=%v, want skip", tt.name, pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 0 {
|
||||
t.Fatalf("%s reconcile sent %d full inbound updates, want 0", tt.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvancePushedInboundRequiresMatchingPreviousFingerprint: if changes were
|
||||
// folded to dirty (or an earlier push failed), the recorded fingerprint no
|
||||
// longer matches the pre-edit payload; a later successful client push must not
|
||||
// mask the pending reconcile.
|
||||
func TestAdvancePushedInboundRequiresMatchingPreviousFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
staleIb := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"folded@x"}]}`}
|
||||
prevIb := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
ib := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"a@x"}]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
r.recordPushedInbound(staleIb)
|
||||
|
||||
if err := r.UpdateUser(context.Background(), ib, "a@x", model.Client{Email: "a@x"}); err != nil {
|
||||
t.Fatalf("client mutation: %v", err)
|
||||
}
|
||||
r.AdvancePushedInbound(prevIb, ib)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("reconcile with unproven pre-state: pushed=%v err=%v, want full push", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 1 {
|
||||
t.Fatalf("reconcile sent %d full inbound updates, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdoptedSerializationChainKeepsReconcileSkip: after a push the node
|
||||
// re-serializes settings its own way and the master adopts that form back into
|
||||
// its DB; stamping the adopted payload keeps edit->advance->skip alive instead
|
||||
// of degrading every edit to a full reconcile push.
|
||||
func TestAdoptedSerializationChainKeepsReconcileSkip(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
pushForm := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"a@x"}]}`}
|
||||
adoptedForm := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: "{\n \"clients\": [{\"email\": \"a@x\"}]\n}"}
|
||||
edited := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: "{\n \"clients\": [{\"comment\": \"c\", \"email\": \"a@x\"}]\n}"}
|
||||
r.cacheSet(pushForm.Tag, 7)
|
||||
|
||||
r.recordPushedInbound(pushForm)
|
||||
r.RecordAdoptedInbound(adoptedForm)
|
||||
if err := r.UpdateUser(context.Background(), edited, "a@x", model.Client{Email: "a@x"}); err != nil {
|
||||
t.Fatalf("client mutation: %v", err)
|
||||
}
|
||||
r.AdvancePushedInbound(adoptedForm, edited)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), edited, true); err != nil || pushed {
|
||||
t.Fatalf("reconcile after adopted-form advance: pushed=%v err=%v, want skip", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 0 {
|
||||
t.Fatalf("full inbound updates=%d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserNotFoundHandling(t *testing.T) {
|
||||
t.Run("envelope not-found counts as already deleted", func(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":false,"msg":"client not found"}`)
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-delete-missing", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
if err := r.DeleteUser(context.Background(), ib, "missing@x"); err != nil {
|
||||
t.Fatalf("DeleteUser missing client: %v", err)
|
||||
}
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("reconcile after missing-client delete: pushed=%v err=%v, want full push", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 1 {
|
||||
t.Fatalf("reconcile sent %d full inbound updates, want 1", got)
|
||||
}
|
||||
})
|
||||
t.Run("http 404 from an old node stays an error", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/panel/api/clients/") {
|
||||
http.Error(w, "404 page not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-delete-404", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
err := r.DeleteUser(context.Background(), ib, "missing@x")
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 404") {
|
||||
t.Fatalf("DeleteUser against old node = %v, want HTTP 404 error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDelInboundDropsReconcileFingerprint: deleting an inbound must forget its
|
||||
// fingerprint so a later same-tag inbound with identical content is re-pushed.
|
||||
func TestDelInboundDropsReconcileFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-del", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed {
|
||||
t.Fatalf("initial reconcile: pushed=%v err=%v, want push", pushed, err)
|
||||
}
|
||||
if err := r.DelInbound(context.Background(), ib); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
}
|
||||
r.cacheSet(ib.Tag, 7)
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed {
|
||||
t.Fatalf("reconcile after DelInbound: pushed=%v err=%v, want full push", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 2 {
|
||||
t.Fatalf("full inbound updates=%d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInboundFallbackAddSeedsReconcileFingerprint(t *testing.T) {
|
||||
srv, counts := newCountingNodeServer(t, `{"success":true}`)
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "in-add-fallback", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`}
|
||||
|
||||
if err := r.UpdateInbound(context.Background(), ib, ib); err != nil {
|
||||
t.Fatalf("UpdateInbound fallback add: %v", err)
|
||||
}
|
||||
if got := counts.adds.Load(); got != 1 {
|
||||
t.Fatalf("fallback add requests=%d, want 1", got)
|
||||
}
|
||||
if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed {
|
||||
t.Fatalf("reconcile after fallback add: pushed=%v err=%v, want skip", pushed, err)
|
||||
}
|
||||
if got := counts.inboundUpdates.Load(); got != 0 {
|
||||
t.Fatalf("reconcile sent %d full inbound updates, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -1,950 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const remoteHTTPTimeout = 10 * time.Second
|
||||
|
||||
// zstdMinBodyBytes is the smallest body worth compressing; below it the framing
|
||||
// overhead can outweigh the savings.
|
||||
const zstdMinBodyBytes = 1024
|
||||
|
||||
// maxRemoteResponseBytes caps a single node RPC's response body. It bounds the
|
||||
// wire/decompressed size of one response — the real guard against a broken or
|
||||
// hostile node streaming an unbounded body. It is NOT a process-wide memory
|
||||
// bound: concurrent RPCs and the decoded JSON can each exceed it, so
|
||||
// endpoint-specific caps and a concurrency budget remain follow-ups. Node
|
||||
// responses (traffic snapshots, client-IP lists, inbound options) are JSON and
|
||||
// stay well under it.
|
||||
const maxRemoteResponseBytes = 64 << 20 // 64 MiB
|
||||
|
||||
// errBodyDiagBytes bounds how much of a non-OK error body we read for a
|
||||
// diagnostic snippet (and to let small-error connections be reused) without
|
||||
// buffering a potentially huge or hostile error payload.
|
||||
const errBodyDiagBytes = 8 << 10 // 8 KiB
|
||||
|
||||
// errRemoteResponseTooLarge is returned when a node response exceeds the cap.
|
||||
var errRemoteResponseTooLarge = errors.New("remote response exceeds size limit")
|
||||
|
||||
// readCappedBody reads all of r but rejects bodies larger than limit, returning
|
||||
// errRemoteResponseTooLarge. It reads at most limit+1 bytes so a body of exactly
|
||||
// limit is accepted and the first oversize byte is detected without buffering
|
||||
// more.
|
||||
func readCappedBody(r io.Reader, limit int64) ([]byte, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, errRemoteResponseTooLarge
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
Obj json.RawMessage `json:"obj"`
|
||||
}
|
||||
|
||||
// remoteAPIError is a node-panel envelope failure (HTTP 200, success=false),
|
||||
// distinct from transport/HTTP-status errors so callers can trust its message.
|
||||
type remoteAPIError struct{ msg string }
|
||||
|
||||
func (e *remoteAPIError) Error() string { return "remote: " + e.msg }
|
||||
|
||||
type Remote struct {
|
||||
node *model.Node
|
||||
|
||||
mu sync.RWMutex
|
||||
remoteIDByTag map[string]int
|
||||
adoptedAliases map[string]string
|
||||
// pushedFP holds the fingerprint of the last inbound wire payload successfully
|
||||
// pushed, keyed by panel-side tag, so reconcile can skip re-sending an
|
||||
// unchanged inbound. Guarded by mu; dropped with the Remote on node config change.
|
||||
pushedFP map[string]string
|
||||
// supportsZstd is learned from the node's X-3x-Node-Caps response header; once
|
||||
// seen, config pushes to this node are zstd-compressed. Old nodes never set
|
||||
// it, so they keep receiving plain bodies (mixed-version safe).
|
||||
supportsZstd bool
|
||||
|
||||
// Per-node client honoring the TLS verify mode, built once and reused; a
|
||||
// node config change drops the cached Remote so the next one rebuilds it.
|
||||
clientOnce sync.Once
|
||||
client *http.Client
|
||||
clientErr error
|
||||
|
||||
egressResolver NodeEgressResolver
|
||||
}
|
||||
|
||||
type RemoteInboundOption struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
Remark string `json:"remark"`
|
||||
Listen string `json:"listen"`
|
||||
Protocol model.Protocol `json:"protocol"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
|
||||
return &Remote{
|
||||
node: n,
|
||||
remoteIDByTag: make(map[string]int),
|
||||
adoptedAliases: make(map[string]string),
|
||||
pushedFP: make(map[string]string),
|
||||
egressResolver: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Remote) Name() string { return "node:" + r.node.Name }
|
||||
|
||||
func (r *Remote) nodeSupportsZstd() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.supportsZstd
|
||||
}
|
||||
|
||||
// recordCaps learns the node's capabilities from a response header so later
|
||||
// pushes can use the negotiated envelope.
|
||||
func (r *Remote) recordCaps(h http.Header) {
|
||||
if !strings.Contains(h.Get(wirecodec.CapsHeader), wirecodec.CapZstd) {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.supportsZstd = true
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// httpClient lazily builds and caches the per-node client honoring the TLS
|
||||
// verify mode, so Remote ops don't fall back to system CA on skip/pin (#5264).
|
||||
func (r *Remote) httpClient() (*http.Client, error) {
|
||||
r.clientOnce.Do(func() {
|
||||
proxyURL := ""
|
||||
if r.node.OutboundTag != "" && r.egressResolver != nil {
|
||||
proxyURL = r.egressResolver.NodeEgressProxyURL(r.node.Id)
|
||||
}
|
||||
r.client, r.clientErr = HTTPClientForNode(r.node, proxyURL)
|
||||
})
|
||||
return r.client, r.clientErr
|
||||
}
|
||||
|
||||
// isAgentPlaceholderAddress reports whether addr is the placeholder the
|
||||
// node form used to store for agent nodes ("agent"). Agent nodes dial out
|
||||
// to the master and expose no panel endpoint; the write path no longer
|
||||
// stores the placeholder (see sanitizeAgentEndpoint in the node service),
|
||||
// but rows written earlier still carry it, and "agent" is a syntactically
|
||||
// valid hostname — without this guard baseURL would splice it into a
|
||||
// dialable URL (https://agent:2053/...) and do() would fire real requests
|
||||
// at whatever "agent" happens to resolve to.
|
||||
func isAgentPlaceholderAddress(addr string) bool {
|
||||
return strings.EqualFold(addr, "agent")
|
||||
}
|
||||
|
||||
func (r *Remote) baseURL() (string, error) {
|
||||
addr, err := netsafe.NormalizeHost(r.node.Address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isAgentPlaceholderAddress(addr) {
|
||||
return "", errors.New("node has no panel endpoint (agent-managed nodes cannot be dialed)")
|
||||
}
|
||||
scheme := r.node.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
if r.node.Port <= 0 || r.node.Port > 65535 {
|
||||
return "", fmt.Errorf("invalid node port %d", r.node.Port)
|
||||
}
|
||||
bp := r.node.BasePath
|
||||
if !strings.HasSuffix(bp, "/") {
|
||||
bp += "/"
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
|
||||
Path: bp,
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
|
||||
// mtls nodes authenticate via the client certificate, so a bearer token is
|
||||
// optional for them; every other mode still requires one.
|
||||
if r.node.ApiToken == "" && r.node.TlsVerifyMode != "mtls" {
|
||||
return nil, errors.New("node has no API token configured")
|
||||
}
|
||||
|
||||
base, err := r.baseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := base + strings.TrimPrefix(path, "/")
|
||||
|
||||
var (
|
||||
bodyBytes []byte
|
||||
contentType string
|
||||
)
|
||||
switch b := body.(type) {
|
||||
case nil:
|
||||
case url.Values:
|
||||
bodyBytes = []byte(b.Encode())
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
default:
|
||||
buf, jerr := json.Marshal(b)
|
||||
if jerr != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", jerr)
|
||||
}
|
||||
bodyBytes = buf
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
// Attach the integrity hash of the uncompressed body unconditionally (a new
|
||||
// node verifies it, an old one ignores it), and zstd-compress only when the
|
||||
// node advertised support and the body is worth it.
|
||||
var (
|
||||
reqBody io.Reader
|
||||
hashHex string
|
||||
zstdEncoded bool
|
||||
)
|
||||
if bodyBytes != nil {
|
||||
hashHex = wirecodec.Sha256Hex(bodyBytes)
|
||||
if len(bodyBytes) >= zstdMinBodyBytes && r.nodeSupportsZstd() {
|
||||
bodyBytes = wirecodec.Compress(bodyBytes)
|
||||
zstdEncoded = true
|
||||
}
|
||||
reqBody = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.node.ApiToken != "" {
|
||||
token, err := nodetoken.Decrypt(r.node.Id, r.node.ApiToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt node token: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if hashHex != "" {
|
||||
req.Header.Set(wirecodec.HashHeader, hashHex)
|
||||
}
|
||||
if zstdEncoded {
|
||||
req.Header.Set("Content-Encoding", wirecodec.EncodingZstd)
|
||||
}
|
||||
|
||||
client, err := r.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
r.recordCaps(resp.Header)
|
||||
|
||||
// Validate status before reading a success payload: a non-OK response's
|
||||
// body is never used beyond a short diagnostic, so don't let a node force us
|
||||
// to buffer a large body just to return an HTTP error.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyDiagBytes))
|
||||
if msg := bytes.TrimSpace(snippet); len(msg) > 0 {
|
||||
// %q quotes/escapes the untrusted node body so control characters or
|
||||
// newlines in it can't garble or inject into the error/log output.
|
||||
return nil, fmt.Errorf("%s %s: HTTP %d: %q", method, path, resp.StatusCode, msg)
|
||||
}
|
||||
return nil, fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Fast-fail on an honestly-declared oversize body; the LimitReader below is
|
||||
// the real guard since Content-Length is untrusted, may be absent, or is -1
|
||||
// under transparent decompression.
|
||||
if resp.ContentLength > maxRemoteResponseBytes {
|
||||
return nil, fmt.Errorf("%s %s: %w (content-length %d, cap %d)", method, path, errRemoteResponseTooLarge, resp.ContentLength, maxRemoteResponseBytes)
|
||||
}
|
||||
|
||||
raw, err := readCappedBody(resp.Body, maxRemoteResponseBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRemoteResponseTooLarge) {
|
||||
return nil, fmt.Errorf("%s %s: %w (cap %d bytes)", method, path, err, maxRemoteResponseBytes)
|
||||
}
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, fmt.Errorf("decode envelope: %w", err)
|
||||
}
|
||||
if !env.Success {
|
||||
return &env, &remoteAPIError{msg: env.Msg}
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
|
||||
if id, ok := r.cacheGetTag(tag); ok {
|
||||
return id, nil
|
||||
}
|
||||
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if id, ok := r.cacheGetTag(tag); ok {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
|
||||
}
|
||||
|
||||
// nodeInboundTagPrefix is the central-panel alias for an inbound on nodeID.
|
||||
// Kept in sync with service.nodeTagPrefix (port_conflict.go); duplicated here
|
||||
// so runtime does not import service.
|
||||
func nodeInboundTagPrefix(nodeID int) string {
|
||||
return fmt.Sprintf("n%d-", nodeID)
|
||||
}
|
||||
|
||||
// stripNodeInboundTagPrefix removes the central-only n<id>- prefix before
|
||||
// pushing an inbound to the node so Xray keeps its original tag and routing.
|
||||
func stripNodeInboundTagPrefix(nodeID int, tag string) string {
|
||||
if stripped, ok := strings.CutPrefix(tag, nodeInboundTagPrefix(nodeID)); ok {
|
||||
return stripped
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
// cacheGetTag looks up a remote inbound id by tag, tolerating an n<id>- prefix
|
||||
// that lives on only one of the two panels: the node may carry the bare tag
|
||||
// while the central panel stores the prefixed form, or vice versa.
|
||||
func (r *Remote) cacheGetTag(tag string) (int, bool) {
|
||||
if id, ok := r.cacheGet(tag); ok {
|
||||
return id, true
|
||||
}
|
||||
prefix := nodeInboundTagPrefix(r.node.Id)
|
||||
if stripped, found := strings.CutPrefix(tag, prefix); found {
|
||||
return r.cacheGet(stripped)
|
||||
}
|
||||
return r.cacheGet(prefix + tag)
|
||||
}
|
||||
|
||||
func (r *Remote) cacheGet(tag string) (int, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
id, ok := r.remoteIDByTag[tag]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (r *Remote) cacheSet(tag string, id int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.remoteIDByTag[tag] = id
|
||||
}
|
||||
|
||||
func (r *Remote) cacheDel(tag string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.remoteIDByTag, tag)
|
||||
delete(r.pushedFP, tag)
|
||||
}
|
||||
|
||||
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
|
||||
if err := r.refreshRemoteIDs(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
tags := make([]string, 0, len(r.remoteIDByTag))
|
||||
for tag := range r.remoteIDByTag {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list []RemoteInboundOption
|
||||
if err := json.Unmarshal(env.Obj, &list); err != nil {
|
||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var list []struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Obj, &list); err != nil {
|
||||
return fmt.Errorf("decode inbound list: %w", err)
|
||||
}
|
||||
next := make(map[string]int, len(list))
|
||||
for _, ib := range list {
|
||||
if ib.Tag == "" {
|
||||
continue
|
||||
}
|
||||
next[ib.Tag] = ib.Id
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.remoteIDByTag = next
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error {
|
||||
payload := wireInbound(ib, r.node.Id)
|
||||
env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var created struct {
|
||||
Id int `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &created); err == nil && created.Id > 0 && created.Tag != "" {
|
||||
r.cacheSet(created.Tag, created.Id)
|
||||
}
|
||||
}
|
||||
r.recordPushedInbound(ib)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) DelInbound(ctx context.Context, ib *model.Inbound) error {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
logger.Warning("remote DelInbound: tag", ib.Tag, "not found on", r.node.Name)
|
||||
return nil
|
||||
}
|
||||
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/del/"+strconv.Itoa(id), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cacheDel(ib.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
||||
id, err := r.resolveRemoteID(ctx, oldIb.Tag)
|
||||
if err != nil {
|
||||
return r.AddInbound(ctx, newIb)
|
||||
}
|
||||
payload := wireInbound(newIb, r.node.Id)
|
||||
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldIb.Tag != newIb.Tag {
|
||||
r.cacheDel(oldIb.Tag)
|
||||
}
|
||||
r.cacheSet(newIb.Tag, id)
|
||||
r.recordPushedInbound(newIb)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) SetInboundSubSortIndex(ctx context.Context, ib *model.Inbound, index int) error {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := url.Values{"subSortIndex": []string{strconv.Itoa(index)}}
|
||||
_, err = r.do(ctx, http.MethodPost, "panel/api/inbounds/"+strconv.Itoa(id)+"/subSortIndex", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReconcileInbound pushes ib only when its wire payload differs from the last
|
||||
// successful push, or when the node no longer reports the tag (existsOnNode
|
||||
// false) — a node that dropped/restarted must still be re-seeded. Returns
|
||||
// whether a push actually happened. This turns a full-fleet reconcile from "send
|
||||
// every inbound's full settings" into "send only what changed".
|
||||
func (r *Remote) ReconcileInbound(ctx context.Context, ib *model.Inbound, existsOnNode bool) (bool, error) {
|
||||
fp := wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
if existsOnNode {
|
||||
r.mu.RLock()
|
||||
prev, ok := r.pushedFP[ib.Tag]
|
||||
r.mu.RUnlock()
|
||||
if ok && prev == fp {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := r.UpdateInbound(ctx, ib, ib); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// recordPushedInbound stamps the fingerprint after a full-payload push — the
|
||||
// only operation that proves the node holds the entire wire payload.
|
||||
func (r *Remote) recordPushedInbound(ib *model.Inbound) {
|
||||
fp := wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
r.mu.Lock()
|
||||
r.pushedFP[ib.Tag] = fp
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// RecordAdoptedInbound stamps the exact payload fingerprint after the master
|
||||
// adopts a node's settings serialization.
|
||||
func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
|
||||
r.recordPushedInbound(ib)
|
||||
}
|
||||
|
||||
// AdoptInboundAlias records a deployed alias without mutating either panel.
|
||||
// The runtime association is rediscovered after a master restart.
|
||||
func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) {
|
||||
r.mu.Lock()
|
||||
r.remoteIDByTag[remote.Tag] = remote.Id
|
||||
r.remoteIDByTag[ib.Tag] = remote.Id
|
||||
r.adoptedAliases[ib.Tag] = remote.Tag
|
||||
r.pushedFP[ib.Tag] = wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Remote) AdoptedInboundAliases() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
aliases := make([]string, 0, len(r.adoptedAliases))
|
||||
for _, alias := range r.adoptedAliases {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
// AdvancePushedInbound moves the reconcile-skip fingerprint from an inbound's
|
||||
// pre-edit payload to its post-edit payload once every per-client push for the
|
||||
// edit succeeded. It advances only when the recorded fingerprint proves the
|
||||
// node held the exact pre-edit state; otherwise the stale fingerprint stays and
|
||||
// the next reconcile re-sends the full inbound.
|
||||
func (r *Remote) AdvancePushedInbound(prevIb, ib *model.Inbound) {
|
||||
prevFP := wireFingerprint(wireInbound(prevIb, r.node.Id))
|
||||
nextFP := wireFingerprint(wireInbound(ib, r.node.Id))
|
||||
r.mu.Lock()
|
||||
if r.pushedFP[ib.Tag] == prevFP {
|
||||
r.pushedFP[ib.Tag] = nextFP
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// wireFingerprint hashes a wire payload so an unchanged inbound is cheap to detect.
|
||||
func wireFingerprint(v url.Values) string {
|
||||
sum := sha256.Sum256([]byte(v.Encode()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (r *Remote) AddUser(ctx context.Context, ib *model.Inbound, _ map[string]any) error {
|
||||
return r.UpdateInbound(ctx, ib, ib)
|
||||
}
|
||||
|
||||
func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) error {
|
||||
return r.UpdateInbound(ctx, ib, ib)
|
||||
}
|
||||
|
||||
func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"client": client,
|
||||
"inboundIds": []int{id},
|
||||
}
|
||||
if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
// Can't confirm the delete reached the node — surface it so the caller
|
||||
// marks the node dirty and a reconcile converges, instead of silently
|
||||
// dropping the delete and letting the next snapshot resurrect the client.
|
||||
return fmt.Errorf("remote DeleteUser: resolve tag %q: %w", ib.Tag, err)
|
||||
}
|
||||
body := map[string]any{"inboundIds": []int{id}}
|
||||
_, err = r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *remoteAPIError
|
||||
if errors.As(err, &apiErr) && strings.Contains(strings.ToLower(apiErr.msg), "not found") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) DeleteClient(ctx context.Context, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/del/"+url.PathEscape(email), nil)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *remoteAPIError
|
||||
if errors.As(err, &apiErr) && strings.Contains(strings.ToLower(apiErr.msg), "not found") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
|
||||
if oldEmail == "" {
|
||||
oldEmail = payload.Email
|
||||
}
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
|
||||
"?inboundIds=" + strconv.Itoa(id)
|
||||
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) RestartXray(ctx context.Context) error {
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePanel asks the node to run its own official self-updater (update.sh)
|
||||
// and restart onto the latest release. The node returns as soon as the job is
|
||||
// launched; the new version surfaces on the next heartbeat. When dev is true the
|
||||
// node is moved to the rolling dev channel instead of the latest stable release.
|
||||
func (r *Remote) UpdatePanel(ctx context.Context, dev bool) error {
|
||||
var body any
|
||||
if dev {
|
||||
body = url.Values{"dev": {"true"}}
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", body)
|
||||
return err
|
||||
}
|
||||
|
||||
// WebCertFiles holds a node's own web TLS certificate and key file paths.
|
||||
type WebCertFiles struct {
|
||||
WebCertFile string `json:"webCertFile"`
|
||||
WebKeyFile string `json:"webKeyFile"`
|
||||
}
|
||||
|
||||
// GetWebCertFiles fetches the node's own web TLS certificate/key file paths so
|
||||
// the central panel can offer them as the "Set Cert from Panel" default for a
|
||||
// node-assigned inbound — those paths exist on the node, the central panel's
|
||||
// don't. See issue #4854.
|
||||
func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/server/getWebCertFiles", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files WebCertFiles
|
||||
if err := json.Unmarshal(env.Obj, &files); err != nil {
|
||||
return nil, fmt.Errorf("decode web cert files: %w", err)
|
||||
}
|
||||
return &files, nil
|
||||
}
|
||||
|
||||
// GetDescendants fetches the node's read-only summaries of the nodes IT
|
||||
// manages, so this panel can surface them as transitive sub-nodes in a chained
|
||||
// topology (#4983). Best-effort: an old-build node without the endpoint returns
|
||||
// an error the caller ignores.
|
||||
func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []model.NodeSummary
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode descendants: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
|
||||
_, err := r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) ResetAllTraffics(ctx context.Context) error {
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/resetAllTraffics", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error {
|
||||
_, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type TrafficSnapshot struct {
|
||||
Inbounds []*model.Inbound
|
||||
OnlineEmails []string
|
||||
ManagedAliases []string
|
||||
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
|
||||
// its panelGuid plus every descendant under theirs). Preferred over the flat
|
||||
// OnlineEmails so the master can attribute deeply nested clients to the real
|
||||
// node across a chain (#4983). Empty when the node is an old build without
|
||||
// the per-GUID endpoint — OnlineEmails is the fallback then.
|
||||
OnlineTree map[string][]string
|
||||
// ActiveInboundTree is the GUID-keyed subtree of inbound tags that carried
|
||||
// traffic within the node's online grace window. Empty when the node is an
|
||||
// old build without the endpoint; the master then falls back to email-only
|
||||
// online attribution for that node.
|
||||
ActiveInboundTree map[string][]string
|
||||
LastOnlineMap map[string]int64
|
||||
// HostGroups carries the node's per-inbound host overrides (TLS/SNI/
|
||||
// fingerprint), fetched only when the snapshot holds a not-yet-adopted tag.
|
||||
HostGroups []*entity.HostGroup
|
||||
}
|
||||
|
||||
// FetchHostGroups pulls the node's host overrides so a freshly adopted inbound
|
||||
// keeps its subscription TLS/SNI/fingerprint settings on the master.
|
||||
func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/hosts/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var groups []*entity.HostGroup
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &groups); err != nil {
|
||||
return nil, fmt.Errorf("decode host groups: %w", err)
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {
|
||||
snap := &TrafficSnapshot{LastOnlineMap: map[string]int64{}}
|
||||
|
||||
envList, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(envList.Obj, &snap.Inbounds); err != nil {
|
||||
return nil, fmt.Errorf("decode inbound list: %w", err)
|
||||
}
|
||||
|
||||
// Prefer the GUID-keyed subtree; fall back to the flat list only when the
|
||||
// node is an old build without the per-GUID endpoint (#4983).
|
||||
envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
|
||||
if err == nil && len(envTree.Obj) > 0 {
|
||||
_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
|
||||
}
|
||||
if len(snap.OnlineTree) == 0 {
|
||||
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
|
||||
if err != nil {
|
||||
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
|
||||
} else if len(envOnlines.Obj) > 0 {
|
||||
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
|
||||
}
|
||||
}
|
||||
|
||||
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
|
||||
if err != nil {
|
||||
logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
|
||||
} else if len(envLastOnline.Obj) > 0 {
|
||||
_ = json.Unmarshal(envLastOnline.Obj, &snap.LastOnlineMap)
|
||||
}
|
||||
|
||||
envActiveInbounds, err := r.do(ctx, http.MethodPost, "panel/api/clients/activeInbounds", nil)
|
||||
if err != nil {
|
||||
logger.Debugf("remote %s active inbounds fetch failed: %v", r.node.Name, err)
|
||||
} else if len(envActiveInbounds.Obj) > 0 {
|
||||
_ = json.Unmarshal(envActiveInbounds.Obj, &snap.ActiveInboundTree)
|
||||
}
|
||||
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// PushGlobalClientTraffics sends this panel's aggregated per-client usage to
|
||||
// the node, tagged with this panel's GUID so the node keeps one row per
|
||||
// pushing master. Display/enforcement input on the node only — the node never
|
||||
// folds these into the counters it reports back, so this panel's (and any
|
||||
// other master's) delta accounting over the node snapshot stays intact.
|
||||
func (r *Remote) PushGlobalClientTraffics(ctx context.Context, masterGuid string, traffics []*xray.ClientTraffic) error {
|
||||
payload := map[string]any{
|
||||
"masterGuid": masterGuid,
|
||||
"traffics": traffics,
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/pushClientTraffics", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
|
||||
v := url.Values{}
|
||||
v.Set("total", strconv.FormatInt(ib.Total, 10))
|
||||
v.Set("remark", ib.Remark)
|
||||
v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
|
||||
v.Set("enable", strconv.FormatBool(ib.Enable))
|
||||
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
|
||||
v.Set("listen", ib.Listen)
|
||||
v.Set("port", strconv.Itoa(ib.Port))
|
||||
v.Set("protocol", string(ib.Protocol))
|
||||
v.Set("settings", ib.Settings)
|
||||
v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
|
||||
tag := ib.Tag
|
||||
if remoteNodeID > 0 {
|
||||
tag = stripNodeInboundTagPrefix(remoteNodeID, tag)
|
||||
}
|
||||
v.Set("tag", tag)
|
||||
v.Set("sniffing", ib.Sniffing)
|
||||
shareAddrStrategy := strings.TrimSpace(ib.ShareAddrStrategy)
|
||||
switch shareAddrStrategy {
|
||||
case "listen", "custom":
|
||||
default:
|
||||
shareAddrStrategy = "node"
|
||||
}
|
||||
v.Set("shareAddrStrategy", shareAddrStrategy)
|
||||
v.Set("shareAddr", ib.ShareAddr)
|
||||
v.Set("disableFlow", strconv.FormatBool(ib.DisableFlow))
|
||||
if ib.TrafficReset != "" {
|
||||
v.Set("trafficReset", ib.TrafficReset)
|
||||
}
|
||||
if ib.TrafficResetDay > 0 {
|
||||
v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
|
||||
// from the StreamSettings before sending to a remote node, but ONLY when
|
||||
// inline certificate content (certificate / key) is also present in the same
|
||||
// entry. In that case the file paths are redundant and stripping them avoids
|
||||
// confusion when the central panel's local paths don't exist on the remote.
|
||||
//
|
||||
// When a certificate entry contains ONLY file paths (no inline content) the
|
||||
// paths are left untouched: the user explicitly entered paths that exist on
|
||||
// the remote node's filesystem, and removing them would leave Xray with TLS
|
||||
// configured but no certificate, causing Xray to crash on the remote node.
|
||||
func sanitizeStreamSettingsForRemote(streamSettings string) string {
|
||||
if streamSettings == "" {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
|
||||
if !ok {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
certificates, ok := tlsSettings["certificates"].([]any)
|
||||
if !ok {
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, cert := range certificates {
|
||||
c, ok := cert.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Only strip file paths when inline content is present so that the
|
||||
// remote Xray still has a valid certificate to use.
|
||||
hasCertFile := c["certificateFile"] != nil && c["certificateFile"] != ""
|
||||
hasKeyFile := c["keyFile"] != nil && c["keyFile"] != ""
|
||||
hasCertInline := isNonEmptySlice(c["certificate"])
|
||||
hasKeyInline := isNonEmptySlice(c["key"])
|
||||
if hasCertFile && hasCertInline {
|
||||
delete(c, "certificateFile")
|
||||
changed = true
|
||||
}
|
||||
if hasKeyFile && hasKeyInline {
|
||||
delete(c, "keyFile")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return streamSettings
|
||||
}
|
||||
out, err := json.Marshal(stream)
|
||||
if err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// isNonEmptySlice reports whether v is a non-nil, non-empty JSON array value.
|
||||
func isNonEmptySlice(v any) bool {
|
||||
s, ok := v.([]any)
|
||||
return ok && len(s) > 0
|
||||
}
|
||||
|
||||
func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIps, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/server/clientIps", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ips []model.InboundClientIps
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &ips); err != nil {
|
||||
return nil, fmt.Errorf("decode client ips: %w", err)
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClientIps) error {
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchClientIpsByGuid pulls the node's per-node IP attribution subtree
|
||||
// (guid -> email -> observed IPs). Unlike FetchAllClientIps (the flat union the
|
||||
// master also pushes back), this preserves which physical node each IP is on.
|
||||
// Returns an empty map for older nodes that lack the endpoint.
|
||||
func (r *Remote) FetchClientIpsByGuid(ctx context.Context) (map[string]map[string][]model.ClientIpEntry, error) {
|
||||
env, err := r.do(ctx, http.MethodPost, "panel/api/clients/clientIpsByGuid", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]map[string][]model.ClientIpEntry{}
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode client ips by guid: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||
)
|
||||
|
||||
// TestRemoteSendsEnvelopeWhenNodeAdvertisesCap: once a node has advertised the
|
||||
// zstd capability (via a response header on any prior call), a large push is
|
||||
// sent zstd-compressed with an X-Config-Sha256 of the *uncompressed* body.
|
||||
func TestRemoteSendsEnvelopeWhenNodeAdvertisesCap(t *testing.T) {
|
||||
var capturedEnc, capturedHash string
|
||||
var capturedBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(wirecodec.CapsHeader, wirecodec.CapZstd) // advertise on every response
|
||||
if r.Method == http.MethodPost {
|
||||
capturedEnc = r.Header.Get("Content-Encoding")
|
||||
capturedHash = r.Header.Get(wirecodec.HashHeader)
|
||||
capturedBody, _ = io.ReadAll(r.Body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
|
||||
// Prime: a prior call learns the cap from the response header.
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil {
|
||||
t.Fatalf("prime call: %v", err)
|
||||
}
|
||||
|
||||
body := url.Values{}
|
||||
body.Set("settings", strings.Repeat("x", 4096))
|
||||
if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
|
||||
if capturedEnc != wirecodec.EncodingZstd {
|
||||
t.Fatalf("Content-Encoding = %q, want %q", capturedEnc, wirecodec.EncodingZstd)
|
||||
}
|
||||
if len(capturedHash) != 64 {
|
||||
t.Fatalf("missing/short X-Config-Sha256: %q", capturedHash)
|
||||
}
|
||||
raw, err := wirecodec.Decompress(capturedBody, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatalf("server could not decompress the body: %v", err)
|
||||
}
|
||||
if string(raw) != body.Encode() {
|
||||
t.Fatalf("decompressed body mismatch: %q != %q", string(raw), body.Encode())
|
||||
}
|
||||
if wirecodec.Sha256Hex(raw) != capturedHash {
|
||||
t.Fatal("X-Config-Sha256 does not match the decompressed body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteSendsPlainWhenNoCap: a node that never advertises the cap (old
|
||||
// build) receives a plain body — but the integrity hash is still attached
|
||||
// (harmless to old nodes, verified by new ones).
|
||||
func TestRemoteSendsPlainWhenNoCap(t *testing.T) {
|
||||
var capturedEnc, capturedHash string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
capturedEnc = r.Header.Get("Content-Encoding")
|
||||
capturedHash = r.Header.Get(wirecodec.HashHeader)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
body := url.Values{}
|
||||
body.Set("settings", strings.Repeat("x", 4096))
|
||||
if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
|
||||
if capturedEnc != "" {
|
||||
t.Fatalf("a no-cap node must receive a plain body, got Content-Encoding=%q", capturedEnc)
|
||||
}
|
||||
if len(capturedHash) != 64 {
|
||||
t.Fatalf("integrity hash should always be sent, got %q", capturedHash)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// nodeForPlainServer builds an http (non-TLS) node so do()'s token handling can
|
||||
// be exercised without TLS scaffolding.
|
||||
func nodeForPlainServer(t *testing.T, srv *httptest.Server, mode, token string) *model.Node {
|
||||
t.Helper()
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse url: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("parse port: %v", err)
|
||||
}
|
||||
return &model.Node{
|
||||
Id: 1, Name: "n1", Scheme: "http", Address: u.Hostname(), Port: port,
|
||||
BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true,
|
||||
TlsVerifyMode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_MTLSNodeNoBearer asserts that an mtls node with no API token
|
||||
// sends its request with NO Authorization header and does not trip the
|
||||
// empty-token precondition; while a non-mtls node with no token still errors.
|
||||
func TestRemoteDo_MTLSNodeNoBearer(t *testing.T) {
|
||||
var reached bool
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("mtls without token sends no Authorization header", func(t *testing.T) {
|
||||
reached, gotAuth = false, "sentinel"
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "mtls", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil {
|
||||
t.Fatalf("mtls node with no token must not error on the token precondition: %v", err)
|
||||
}
|
||||
if !reached {
|
||||
t.Fatal("request did not reach the server")
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("Authorization header = %q, want empty for a tokenless mtls node", gotAuth)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-mtls without token still errors", func(t *testing.T) {
|
||||
reached = false
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err == nil {
|
||||
t.Fatal("non-mtls node with no token must still error")
|
||||
}
|
||||
if reached {
|
||||
t.Fatal("non-mtls tokenless request must not reach the server")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestRemoteDo_RejectsOversizeResponse: a node streaming a body larger than
|
||||
// maxRemoteResponseBytes must error out instead of the master buffering it
|
||||
// unbounded.
|
||||
func TestRemoteDo_RejectsOversizeResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
chunk := bytes.Repeat([]byte("a"), 1<<20) // 1 MiB
|
||||
for written := 0; written <= maxRemoteResponseBytes; written += len(chunk) {
|
||||
if _, err := w.Write(chunk); err != nil {
|
||||
return // client stopped reading at the cap
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodGet, "/probe", nil); !errors.Is(err, errRemoteResponseTooLarge) {
|
||||
t.Fatalf("do() error = %v, want errRemoteResponseTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_AcceptsNormalResponse confirms the cap does not break a normal
|
||||
// under-limit envelope.
|
||||
func TestRemoteDo_AcceptsNormalResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":{"x":1}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
env, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("do() unexpected error: %v", err)
|
||||
}
|
||||
if env == nil || !env.Success {
|
||||
t.Fatalf("env = %+v, want Success=true", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSetInboundSubSortIndexSendsOnlyNarrowField(t *testing.T) {
|
||||
var posted url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.URL.Path {
|
||||
case "/panel/api/inbounds/list":
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":[{"id":42,"tag":"remote-tag"}]}`))
|
||||
case "/panel/api/inbounds/42/subSortIndex":
|
||||
if err := req.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm: %v", err)
|
||||
}
|
||||
posted = req.PostForm
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
|
||||
ib := &model.Inbound{Tag: "remote-tag", Settings: `{"clients":[{"email":"newer"}]}`}
|
||||
if err := r.SetInboundSubSortIndex(context.Background(), ib, 7); err != nil {
|
||||
t.Fatalf("SetInboundSubSortIndex: %v", err)
|
||||
}
|
||||
if got := posted.Get("subSortIndex"); got != "7" {
|
||||
t.Fatalf("subSortIndex = %q, want 7", got)
|
||||
}
|
||||
if len(posted) != 1 {
|
||||
t.Fatalf("posted fields = %v, want only subSortIndex", posted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs):
|
||||
// a body of exactly limit is accepted; limit+1 and beyond are rejected.
|
||||
func TestReadCappedBody_Boundary(t *testing.T) {
|
||||
const limit = 8
|
||||
cases := []struct {
|
||||
name string
|
||||
n int
|
||||
wantErr bool
|
||||
}{
|
||||
{"under", limit - 1, false},
|
||||
{"exact", limit, false},
|
||||
{"over-by-one", limit + 1, true},
|
||||
{"way-over", limit * 4, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw, err := readCappedBody(bytes.NewReader(bytes.Repeat([]byte("x"), c.n)), limit)
|
||||
if c.wantErr {
|
||||
if !errors.Is(err, errRemoteResponseTooLarge) {
|
||||
t.Fatalf("n=%d: err=%v, want errRemoteResponseTooLarge", c.n, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("n=%d: unexpected err %v", c.n, err)
|
||||
}
|
||||
if len(raw) != c.n {
|
||||
t.Fatalf("n=%d: read %d bytes, want %d", c.n, len(raw), c.n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteDo_NonOKStatusReturnsHTTPError confirms a non-OK status is reported
|
||||
// as an HTTP error (with a bounded diagnostic snippet) rather than being read as
|
||||
// a success payload — i.e. status precedence over the body.
|
||||
func TestRemoteDo_NonOKStatusReturnsHTTPError(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err == nil {
|
||||
t.Fatal("do() error = nil, want HTTP 500 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 500") || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("error = %q, want it to mention HTTP 500 and the body snippet", err)
|
||||
}
|
||||
}
|
||||
|
||||
type stubEgress struct{ url string }
|
||||
|
||||
func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
|
||||
|
||||
// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
|
||||
// sits on only one side: the node may store the bare tag while the central
|
||||
// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
|
||||
// the push create a duplicate inbound on the node.
|
||||
func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cacheTag string
|
||||
lookup string
|
||||
wantID int
|
||||
wantFound bool
|
||||
}{
|
||||
{"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true},
|
||||
{"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true},
|
||||
{"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true},
|
||||
{"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Id: 1, Name: "n1"}, nil)
|
||||
r.cacheSet(c.cacheTag, 7)
|
||||
id, ok := r.cacheGetTag(c.lookup)
|
||||
if ok != c.wantFound || id != c.wantID {
|
||||
t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundIncludesShareAddressFields(t *testing.T) {
|
||||
values := wireInbound(&model.Inbound{
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: "edge.example.com",
|
||||
}, 0)
|
||||
|
||||
if got := values.Get("shareAddrStrategy"); got != "custom" {
|
||||
t.Fatalf("shareAddrStrategy = %q, want custom", got)
|
||||
}
|
||||
if got := values.Get("shareAddr"); got != "edge.example.com" {
|
||||
t.Fatalf("shareAddr = %q, want edge.example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that does not mirror DisableFlow re-injects Vision into its own xray
|
||||
// config and share links, undoing the opt-out on every multi-node deployment.
|
||||
func TestWireInboundCarriesDisableFlow(t *testing.T) {
|
||||
if got := wireInbound(&model.Inbound{DisableFlow: true}, 0).Get("disableFlow"); got != "true" {
|
||||
t.Fatalf("disableFlow = %q, want true", got)
|
||||
}
|
||||
if got := wireInbound(&model.Inbound{}, 0).Get("disableFlow"); got != "false" {
|
||||
t.Fatalf("disableFlow = %q, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteHTTPClientEgressProxy(t *testing.T) {
|
||||
// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
|
||||
withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})
|
||||
c, err := withTag.httpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("httpClient: %v", err)
|
||||
}
|
||||
if c == defaultNodeHTTPClient {
|
||||
t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default")
|
||||
}
|
||||
// No OutboundTag → no egress proxy → shared default client (verify mode).
|
||||
noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"})
|
||||
c2, err := noTag.httpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("httpClient: %v", err)
|
||||
}
|
||||
if c2 != defaultNodeHTTPClient {
|
||||
t.Fatal("no OutboundTag must use the shared default client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteDoSetsContentType(t *testing.T) {
|
||||
var gotCT string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
|
||||
if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if gotCT != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteBaseURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
scheme string
|
||||
port int
|
||||
bp string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"https default path", "https", 443, "", "https://example.com:443/", false},
|
||||
{"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false},
|
||||
{"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false},
|
||||
{"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false},
|
||||
{"port zero rejected", "https", 0, "/", "", true},
|
||||
{"port above range rejected", "https", 65536, "/", "", true},
|
||||
{"negative port rejected", "https", -1, "/", "", true},
|
||||
{"max port accepted", "https", 65535, "/", "https://example.com:65535/", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil)
|
||||
got, err := r.baseURL()
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("baseURL = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy agent-node rows store the node form's placeholder pair
|
||||
// (address "agent", port 2053). "agent" is a syntactically valid
|
||||
// hostname, so without a guard baseURL would splice it into a dialable
|
||||
// URL — https://agent:2053/... — and do() would fire real requests at
|
||||
// whatever "agent" resolves to. The placeholder must be refused with a
|
||||
// clear endpoint error instead, in any casing.
|
||||
func TestRemoteBaseURL_RejectsAgentPlaceholder(t *testing.T) {
|
||||
for _, addr := range []string{"agent", "Agent", "AGENT"} {
|
||||
r := NewRemote(&model.Node{Address: addr, Port: 2053, Scheme: "https"}, nil)
|
||||
got, err := r.baseURL()
|
||||
if err == nil || !strings.Contains(err.Error(), "no panel endpoint") {
|
||||
t.Fatalf("baseURL(%q) = %q, %v; want a panel-endpoint error", addr, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The latent trigger: a placeholder row that also carries a panel API
|
||||
// token passes do()'s credential gate and reaches baseURL. The request
|
||||
// must die there — never on the network.
|
||||
func TestRemoteDo_AgentPlaceholderNeverDials(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Address: "agent", Port: 2053, Scheme: "https", ApiToken: "tok"}, nil)
|
||||
_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "no panel endpoint") {
|
||||
t.Fatalf("do() error = %v, want a panel-endpoint error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNonEmptySlice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want bool
|
||||
}{
|
||||
{"non-empty slice", []any{1}, true},
|
||||
{"empty slice", []any{}, false},
|
||||
{"nil slice", []any(nil), false},
|
||||
{"not a slice", "x", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isNonEmptySlice(c.in); got != c.want {
|
||||
t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundTrafficReset(t *testing.T) {
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
|
||||
if got := with.Get("trafficReset"); got != "monthly" {
|
||||
t.Fatalf("trafficReset = %q, want monthly", got)
|
||||
}
|
||||
if got := with.Get("trafficResetDay"); got != "15" {
|
||||
t.Fatalf("trafficResetDay = %q, want 15", got)
|
||||
}
|
||||
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
|
||||
without := wireInbound(&model.Inbound{}, 0)
|
||||
if without.Has("trafficReset") {
|
||||
t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
|
||||
values := wireInbound(&model.Inbound{}, 0)
|
||||
|
||||
if got := values.Get("shareAddrStrategy"); got != "node" {
|
||||
t.Fatalf("shareAddrStrategy = %q, want node", got)
|
||||
}
|
||||
|
||||
values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"}, 0)
|
||||
if got := values.Get("shareAddrStrategy"); got != "node" {
|
||||
t.Fatalf("invalid shareAddrStrategy = %q, want node", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNodeInboundTagPrefix(t *testing.T) {
|
||||
cases := []struct {
|
||||
nodeID int
|
||||
tag string
|
||||
want string
|
||||
}{
|
||||
{2, "n2-in-443-tcp", "in-443-tcp"},
|
||||
{2, "in-443-tcp", "in-443-tcp"},
|
||||
{2, "my-custom", "my-custom"},
|
||||
{2, "n3-in-443-tcp", "n3-in-443-tcp"},
|
||||
{0, "n2-in-443-tcp", "n2-in-443-tcp"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripNodeInboundTagPrefix(c.nodeID, c.tag); got != c.want {
|
||||
t.Fatalf("stripNodeInboundTagPrefix(%d, %q) = %q, want %q", c.nodeID, c.tag, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireInboundStripsNodeTagOnPush(t *testing.T) {
|
||||
values := wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 2)
|
||||
if got := values.Get("tag"); got != "in-443-tcp" {
|
||||
t.Fatalf("tag = %q, want in-443-tcp", got)
|
||||
}
|
||||
values = wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 0)
|
||||
if got := values.Get("tag"); got != "n2-in-443-tcp" {
|
||||
t.Fatalf("nodeID 0 must not strip, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
// wantCertFile / wantKeyFile: expected presence after sanitize
|
||||
wantCertFile bool
|
||||
wantKeyFile bool
|
||||
}{
|
||||
{
|
||||
name: "file paths only — kept intact (remote node paths)",
|
||||
input: `{
|
||||
"tlsSettings": {
|
||||
"certificates": [{
|
||||
"certificateFile": "/etc/ssl/cert.crt",
|
||||
"keyFile": "/etc/ssl/key.key"
|
||||
}]
|
||||
}
|
||||
}`,
|
||||
wantCertFile: true,
|
||||
wantKeyFile: true,
|
||||
},
|
||||
{
|
||||
name: "inline content only — unchanged",
|
||||
input: `{
|
||||
"tlsSettings": {
|
||||
"certificates": [{
|
||||
"certificate": ["-----BEGIN CERTIFICATE-----"],
|
||||
"key": ["-----BEGIN PRIVATE KEY-----"]
|
||||
}]
|
||||
}
|
||||
}`,
|
||||
wantCertFile: false,
|
||||
wantKeyFile: false,
|
||||
},
|
||||
{
|
||||
name: "both file paths and inline content — file paths stripped (redundant)",
|
||||
input: `{
|
||||
"tlsSettings": {
|
||||
"certificates": [{
|
||||
"certificateFile": "/etc/ssl/cert.crt",
|
||||
"keyFile": "/etc/ssl/key.key",
|
||||
"certificate": ["-----BEGIN CERTIFICATE-----"],
|
||||
"key": ["-----BEGIN PRIVATE KEY-----"]
|
||||
}]
|
||||
}
|
||||
}`,
|
||||
wantCertFile: false,
|
||||
wantKeyFile: false,
|
||||
},
|
||||
{
|
||||
name: "empty stream settings",
|
||||
input: "",
|
||||
// empty input returns empty, nothing to check
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.input == "" {
|
||||
if got := sanitizeStreamSettingsForRemote(tc.input); got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := sanitizeStreamSettingsForRemote(tc.input)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got)
|
||||
}
|
||||
|
||||
tls, _ := out["tlsSettings"].(map[string]any)
|
||||
certs, _ := tls["certificates"].([]any)
|
||||
if len(certs) == 0 {
|
||||
t.Fatal("certificates array missing in output")
|
||||
}
|
||||
cert, _ := certs[0].(map[string]any)
|
||||
|
||||
_, hasCertFile := cert["certificateFile"]
|
||||
_, hasKeyFile := cert["keyFile"]
|
||||
|
||||
if hasCertFile != tc.wantCertFile {
|
||||
t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile)
|
||||
}
|
||||
if hasKeyFile != tc.wantKeyFile {
|
||||
t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
)
|
||||
|
||||
// MasterClientCertProvider supplies the master client certificate this panel
|
||||
// presents to nodes in mtls mode. It is injected by the web layer so the
|
||||
// runtime package need not import service.
|
||||
type MasterClientCertProvider func() (tls.Certificate, error)
|
||||
|
||||
var (
|
||||
masterClientCertMu sync.RWMutex
|
||||
masterClientCert MasterClientCertProvider
|
||||
masterCertEpoch atomic.Uint64
|
||||
)
|
||||
|
||||
// SetMasterClientCertProvider installs the provider used to obtain the master
|
||||
// client certificate for mtls nodes. Passing nil disables it.
|
||||
func SetMasterClientCertProvider(p MasterClientCertProvider) {
|
||||
masterClientCertMu.Lock()
|
||||
defer masterClientCertMu.Unlock()
|
||||
masterClientCert = p
|
||||
}
|
||||
|
||||
func getMasterClientCert() (tls.Certificate, error) {
|
||||
masterClientCertMu.RLock()
|
||||
p := masterClientCert
|
||||
masterClientCertMu.RUnlock()
|
||||
if p == nil {
|
||||
return tls.Certificate{}, common.NewError("mtls: master client certificate provider not configured")
|
||||
}
|
||||
return p()
|
||||
}
|
||||
|
||||
// InvalidateMasterClientConnections advances the client-credential generation.
|
||||
// Every cached mTLS transport observes the generation before its next request,
|
||||
// replaces its TLS transport, and closes the old idle pool. Requests already
|
||||
// in flight are not interrupted; no request that starts after invalidation can
|
||||
// reuse a connection authenticated with the previous leaf.
|
||||
func InvalidateMasterClientConnections() {
|
||||
masterCertEpoch.Add(1)
|
||||
}
|
||||
|
||||
// ReloadMasterClientConnections validates that the currently configured
|
||||
// provider can load the master credential, then invalidates every cached mTLS
|
||||
// transport. Operators that rotate the credential outside the process (for
|
||||
// example by restoring settings) can call this without restarting the panel.
|
||||
func ReloadMasterClientConnections() error {
|
||||
if _, err := getMasterClientCert(); err != nil {
|
||||
return err
|
||||
}
|
||||
InvalidateMasterClientConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
type idleClosingRoundTripper interface {
|
||||
http.RoundTripper
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
type credentialRotatingTransport struct {
|
||||
mu sync.Mutex
|
||||
generation uint64
|
||||
current idleClosingRoundTripper
|
||||
build func() (idleClosingRoundTripper, error)
|
||||
}
|
||||
|
||||
func buildStableCredentialTransport(build func() (idleClosingRoundTripper, error)) (idleClosingRoundTripper, uint64, error) {
|
||||
for {
|
||||
before := masterCertEpoch.Load()
|
||||
current, err := build()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
after := masterCertEpoch.Load()
|
||||
if before == after {
|
||||
return current, after, nil
|
||||
}
|
||||
current.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func newCredentialRotatingTransport(build func() (idleClosingRoundTripper, error)) (*credentialRotatingTransport, error) {
|
||||
current, generation, err := buildStableCredentialTransport(build)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &credentialRotatingTransport{
|
||||
generation: generation,
|
||||
current: current,
|
||||
build: build,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *credentialRotatingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.mu.Lock()
|
||||
if masterCertEpoch.Load() != t.generation {
|
||||
next, generation, err := buildStableCredentialTransport(t.build)
|
||||
if err != nil {
|
||||
t.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
previous := t.current
|
||||
t.current = next
|
||||
t.generation = generation
|
||||
previous.CloseIdleConnections()
|
||||
}
|
||||
current := t.current
|
||||
t.mu.Unlock()
|
||||
return current.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *credentialRotatingTransport) CloseIdleConnections() {
|
||||
t.mu.Lock()
|
||||
current := t.current
|
||||
t.mu.Unlock()
|
||||
current.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
|
||||
// mode or plain http); shared so connections pool across nodes.
|
||||
var defaultNodeHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
},
|
||||
}
|
||||
|
||||
func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
|
||||
mode := n.TlsVerifyMode
|
||||
if mode == "" {
|
||||
mode = "verify"
|
||||
}
|
||||
if proxyURL != "" {
|
||||
if mode == "mtls" && n.Scheme != "http" {
|
||||
timeout := remoteHTTPTimeout
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, common.NewError("mtls proxy client transport does not support credential rotation")
|
||||
}
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
return transport, nil
|
||||
}
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: timeout}, nil
|
||||
}
|
||||
client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mode == "verify" || n.Scheme == "http" {
|
||||
return client, nil
|
||||
}
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
return client, nil
|
||||
}
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
return client, nil
|
||||
}
|
||||
if mode == "verify" || n.Scheme == "http" {
|
||||
return defaultNodeHTTPClient, nil
|
||||
}
|
||||
if mode == "mtls" {
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: tlsCfg,
|
||||
}, nil
|
||||
}
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{Transport: transport}, nil
|
||||
}
|
||||
tlsCfg, err := tlsConfigForNode(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
TLSClientConfig: tlsCfg,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tlsConfigForNode(n *model.Node) (*tls.Config, error) {
|
||||
if n.TlsVerifyMode == "mtls" {
|
||||
// Present the master client cert; verify the node's server cert against
|
||||
// the system roots (no InsecureSkipVerify). mtls authenticates the
|
||||
// caller — it does not change how the node's server identity is checked.
|
||||
cert, err := getMasterClientCert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}, nil
|
||||
}
|
||||
tlsCfg := &tls.Config{InsecureSkipVerify: true} // lgtm[go/disabled-certificate-check]
|
||||
if n.TlsVerifyMode == "pin" {
|
||||
want, err := DecodeCertPin(n.PinnedCertSha256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return common.NewError("node presented no certificate")
|
||||
}
|
||||
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
|
||||
if subtle.ConstantTimeCompare(sum[:], want) != 1 {
|
||||
return common.NewError("node certificate does not match pinned SHA-256")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return tlsCfg, nil
|
||||
}
|
||||
|
||||
// DecodeCertPin decodes a SHA-256 cert pin given as base64 (Xray's
|
||||
// pinnedPeerCertSha256 form) or hex with optional colons into 32 raw bytes.
|
||||
func DecodeCertPin(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, common.NewError("certificate pin is empty")
|
||||
}
|
||||
if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
|
||||
return b, nil
|
||||
}
|
||||
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
|
||||
if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pgregory.net/rapid"
|
||||
)
|
||||
|
||||
func insertColons(h string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(h); i += 2 {
|
||||
if i > 0 {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
b.WriteString(h[i : i+2])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestProp_DecodeCertPin_FormatAgnostic asserts that for ANY 32-byte pin, every
|
||||
// accepted encoding (hex lower/upper, openssl colon-hex, base64 std/raw/url) decodes
|
||||
// back to the same bytes. Generalizes the fixed-input TestDecodeCertPin so a mutant
|
||||
// that breaks one decoding path is caught across the whole input space.
|
||||
func TestProp_DecodeCertPin_FormatAgnostic(t *testing.T) {
|
||||
rapid.Check(t, func(t *rapid.T) {
|
||||
raw := rapid.SliceOfN(rapid.Byte(), sha256.Size, sha256.Size).Draw(t, "raw")
|
||||
hx := hex.EncodeToString(raw)
|
||||
forms := []string{
|
||||
hx,
|
||||
strings.ToUpper(hx),
|
||||
insertColons(hx),
|
||||
base64.StdEncoding.EncodeToString(raw),
|
||||
base64.RawStdEncoding.EncodeToString(raw),
|
||||
base64.URLEncoding.EncodeToString(raw),
|
||||
base64.RawURLEncoding.EncodeToString(raw),
|
||||
}
|
||||
for _, f := range forms {
|
||||
got, err := DecodeCertPin(f)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeCertPin(%q) errored: %v", f, err)
|
||||
}
|
||||
if !bytes.Equal(got, raw) {
|
||||
t.Fatalf("DecodeCertPin(%q) = %x, want %x", f, got, raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzDecodeCertPin asserts the security-load-bearing decoder never panics, never
|
||||
// returns a non-32-byte slice with a nil error, and never returns bytes alongside an
|
||||
// error. Seeded from the known-good/known-bad cases.
|
||||
func FuzzDecodeCertPin(f *testing.F) {
|
||||
seed := sha256.Sum256([]byte("seed"))
|
||||
f.Add(hex.EncodeToString(seed[:]))
|
||||
f.Add(base64.StdEncoding.EncodeToString(seed[:]))
|
||||
f.Add(insertColons(hex.EncodeToString(seed[:])))
|
||||
f.Add("")
|
||||
f.Add("not-a-pin")
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
got, err := DecodeCertPin(s)
|
||||
if err == nil && len(got) != sha256.Size {
|
||||
t.Fatalf("DecodeCertPin(%q): nil error but %d bytes, want %d", s, len(got), sha256.Size)
|
||||
}
|
||||
if err != nil && got != nil {
|
||||
t.Fatalf("DecodeCertPin(%q): error %v but returned bytes %x", s, err, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
)
|
||||
|
||||
type generationProbeTransport struct {
|
||||
id string
|
||||
closed atomic.Int32
|
||||
}
|
||||
|
||||
func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: http.NoBody,
|
||||
Header: make(http.Header),
|
||||
Request: &http.Request{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *generationProbeTransport) CloseIdleConnections() {
|
||||
t.closed.Add(1)
|
||||
}
|
||||
|
||||
func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) {
|
||||
var selected atomic.Pointer[generationProbeTransport]
|
||||
oldTransport := &generationProbeTransport{id: "old"}
|
||||
newTransport := &generationProbeTransport{id: "new"}
|
||||
selected.Store(oldTransport)
|
||||
|
||||
rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) {
|
||||
return selected.Load(), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newCredentialRotatingTransport: %v", err)
|
||||
}
|
||||
rotating.mu.Lock()
|
||||
initial := rotating.current
|
||||
rotating.mu.Unlock()
|
||||
if initial != oldTransport {
|
||||
t.Fatalf("initial transport = %p, want old %p", initial, oldTransport)
|
||||
}
|
||||
|
||||
selected.Store(newTransport)
|
||||
InvalidateMasterClientConnections()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil)
|
||||
resp, err := rotating.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip after credential rotation: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
rotating.mu.Lock()
|
||||
current := rotating.current
|
||||
rotating.mu.Unlock()
|
||||
if current != newTransport {
|
||||
t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport)
|
||||
}
|
||||
if got := oldTransport.closed.Load(); got != 1 {
|
||||
t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
|
||||
before := masterCertEpoch.Load()
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
return tls.Certificate{}, context.Canceled
|
||||
})
|
||||
if err := ReloadMasterClientConnections(); err == nil {
|
||||
t.Fatal("reload with an invalid provider unexpectedly succeeded")
|
||||
}
|
||||
if got := masterCertEpoch.Load(); got != before {
|
||||
t.Fatalf("failed reload changed generation from %d to %d", before, got)
|
||||
}
|
||||
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
return masterCertForTest(t), nil
|
||||
})
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
if err := ReloadMasterClientConnections(); err != nil {
|
||||
t.Fatalf("ReloadMasterClientConnections: %v", err)
|
||||
}
|
||||
if got := masterCertEpoch.Load(); got != before+1 {
|
||||
t.Fatalf("successful reload generation = %d, want %d", got, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) {
|
||||
oldTransport := &generationProbeTransport{id: "old"}
|
||||
newTransport := &generationProbeTransport{id: "new"}
|
||||
var selected atomic.Pointer[generationProbeTransport]
|
||||
selected.Store(oldTransport)
|
||||
|
||||
firstBuildCaptured := make(chan struct{})
|
||||
releaseFirstBuild := make(chan struct{})
|
||||
var once sync.Once
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
captured := selected.Load()
|
||||
once.Do(func() {
|
||||
close(firstBuildCaptured)
|
||||
<-releaseFirstBuild
|
||||
})
|
||||
return captured, nil
|
||||
}
|
||||
|
||||
type result struct {
|
||||
transport *credentialRotatingTransport
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
resultCh <- result{transport: transport, err: err}
|
||||
}()
|
||||
|
||||
<-firstBuildCaptured
|
||||
selected.Store(newTransport)
|
||||
InvalidateMasterClientConnections()
|
||||
close(releaseFirstBuild)
|
||||
|
||||
got := <-resultCh
|
||||
if got.err != nil {
|
||||
t.Fatalf("newCredentialRotatingTransport: %v", got.err)
|
||||
}
|
||||
got.transport.mu.Lock()
|
||||
current := got.transport.current
|
||||
got.transport.mu.Unlock()
|
||||
if current != newTransport {
|
||||
t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport)
|
||||
}
|
||||
if calls := oldTransport.closed.Load(); calls != 1 {
|
||||
t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) {
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
selected := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
|
||||
client, err := HTTPClientForNode(&model.Node{
|
||||
Scheme: "https",
|
||||
Address: "node.example.test",
|
||||
Port: 443,
|
||||
TlsVerifyMode: "mtls",
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
rotating, ok := client.Transport.(*credentialRotatingTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport)
|
||||
}
|
||||
leaf := func() []byte {
|
||||
rotating.mu.Lock()
|
||||
defer rotating.mu.Unlock()
|
||||
transport, ok := rotating.current.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
|
||||
}
|
||||
return transport.TLSClientConfig.Certificates[0].Certificate[0]
|
||||
}
|
||||
if got := leaf(); string(got) != string(oldCert.Certificate[0]) {
|
||||
t.Fatal("initial TLS config does not contain the old credential")
|
||||
}
|
||||
|
||||
selected = newCert
|
||||
InvalidateMasterClientConnections()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequestWithContext: %v", err)
|
||||
}
|
||||
if _, err := client.Do(req); err == nil {
|
||||
t.Fatal("canceled request unexpectedly succeeded")
|
||||
}
|
||||
if got := leaf(); string(got) != string(newCert.Certificate[0]) {
|
||||
t.Fatal("TLS config retained the old credential after invalidation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) {
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
selected := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
|
||||
const proxyURL = "http://127.0.0.1:18080"
|
||||
client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
rotating, ok := client.Transport.(*credentialRotatingTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport = %T, want rotating transport", client.Transport)
|
||||
}
|
||||
current := func() *http.Transport {
|
||||
rotating.mu.Lock()
|
||||
defer rotating.mu.Unlock()
|
||||
transport, ok := rotating.current.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
|
||||
}
|
||||
return transport
|
||||
}
|
||||
assertProxy := func(transport *http.Transport) {
|
||||
t.Helper()
|
||||
if transport.Proxy == nil {
|
||||
t.Fatalf("proxy function is nil, want %s", proxyURL)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil)
|
||||
got, err := transport.Proxy(req)
|
||||
if err != nil || got == nil || got.String() != proxyURL {
|
||||
t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL)
|
||||
}
|
||||
}
|
||||
assertProxy(current())
|
||||
|
||||
selected = newCert
|
||||
InvalidateMasterClientConnections()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
|
||||
_, _ = client.Do(req)
|
||||
rebuilt := current()
|
||||
assertProxy(rebuilt)
|
||||
if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) {
|
||||
t.Fatal("proxy mTLS rebuild retained the old credential")
|
||||
}
|
||||
}
|
||||
|
||||
// masterCertForTest builds a real CA-signed client certificate for mtls tests.
|
||||
func masterCertForTest(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
ca, err := crypto.GenerateNodeCA("test ca")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateNodeCA: %v", err)
|
||||
}
|
||||
client, err := crypto.IssueClientCert(ca, "master")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueClientCert: %v", err)
|
||||
}
|
||||
cert, err := tls.X509KeyPair(client.CertPEM, client.KeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("X509KeyPair: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
// TestTLSConfigForNode_MTLS_PresentsClientCert asserts the mtls branch presents
|
||||
// the master client cert and verifies the node's server cert against system
|
||||
// roots (no InsecureSkipVerify, no custom RootCAs).
|
||||
func TestTLSConfigForNode_MTLS_PresentsClientCert(t *testing.T) {
|
||||
cert := masterCertForTest(t)
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) { return cert, nil })
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
|
||||
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"})
|
||||
if err != nil {
|
||||
t.Fatalf("tlsConfigForNode(mtls): %v", err)
|
||||
}
|
||||
if len(cfg.Certificates) != 1 {
|
||||
t.Fatalf("mtls config must present exactly one client certificate, got %d", len(cfg.Certificates))
|
||||
}
|
||||
if cfg.InsecureSkipVerify {
|
||||
t.Fatal("mtls must NOT skip server verification")
|
||||
}
|
||||
if cfg.RootCAs != nil {
|
||||
t.Fatal("mtls verifies the node server against system roots (RootCAs must be nil)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLSConfigForNode_MTLS_NoProviderFailsClosed asserts mtls fails closed when
|
||||
// no master client certificate is available, rather than silently dropping auth.
|
||||
func TestTLSConfigForNode_MTLS_NoProviderFailsClosed(t *testing.T) {
|
||||
SetMasterClientCertProvider(nil)
|
||||
if _, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}); err == nil {
|
||||
t.Fatal("mtls without a configured client cert provider must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
// nodeForServer builds a node pointing at a loopback test server (loopback is
|
||||
// SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer).
|
||||
func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node {
|
||||
t.Helper()
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server url: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("parse server port: %v", err)
|
||||
}
|
||||
return &model.Node{
|
||||
Id: 1,
|
||||
Name: "n1",
|
||||
Scheme: "https",
|
||||
Address: u.Hostname(),
|
||||
Port: port,
|
||||
BasePath: "/",
|
||||
ApiToken: "token",
|
||||
Enable: true,
|
||||
AllowPrivateAddress: true,
|
||||
TlsVerifyMode: mode,
|
||||
PinnedCertSha256: pin,
|
||||
}
|
||||
}
|
||||
|
||||
func leafPinBase64(srv *httptest.Server) string {
|
||||
sum := sha256.Sum256(srv.Certificate().Raw)
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// A self-signed node must be reachable by Remote ops under skip/pin and
|
||||
// rejected under verify — the split issue #5264 reported.
|
||||
func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
goodPin := leafPinBase64(srv)
|
||||
wrongPin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mode string
|
||||
pin string
|
||||
wantErr bool
|
||||
}{
|
||||
{"verify rejects self-signed", "verify", "", true},
|
||||
{"skip accepts self-signed", "skip", "", false},
|
||||
{"pin accepts matching cert", "pin", goodPin, false},
|
||||
{"pin rejects mismatched cert", "pin", wrongPin, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := NewRemote(nodeForServer(t, srv, c.mode, c.pin), nil)
|
||||
_, err := r.ListInboundOptions(context.Background())
|
||||
if c.wantErr && err == nil {
|
||||
t.Fatalf("mode %q: expected error, got nil", c.mode)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Fatalf("mode %q: unexpected error: %v", c.mode, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The lazily-built client is cached for the Remote's lifetime so repeated
|
||||
// operations reuse one pooled transport rather than rebuilding TLS each call.
|
||||
func TestRemoteClientCached(t *testing.T) {
|
||||
r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"}, nil)
|
||||
c1, err1 := r.httpClient()
|
||||
c2, err2 := r.httpClient()
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("httpClient errors: %v %v", err1, err2)
|
||||
}
|
||||
if c1 != c2 {
|
||||
t.Fatal("expected the same cached client across calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodeVerifyShared(t *testing.T) {
|
||||
// verify mode and plain http both reuse the shared default client.
|
||||
for _, n := range []*model.Node{
|
||||
{Scheme: "https", TlsVerifyMode: "verify"},
|
||||
{Scheme: "https", TlsVerifyMode: ""},
|
||||
{Scheme: "http", TlsVerifyMode: "skip"},
|
||||
} {
|
||||
c, err := HTTPClientForNode(n, "")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
|
||||
}
|
||||
if c != defaultNodeHTTPClient {
|
||||
t.Fatalf("HTTPClientForNode(%+v) = %p, want shared default %p", n, c, defaultNodeHTTPClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientForNodePinInvalid(t *testing.T) {
|
||||
// pin mode must fail closed, and with a specific error per cause — not merely
|
||||
// "some error" (which a bug anywhere in the build path would also satisfy).
|
||||
cases := []struct {
|
||||
name string
|
||||
pin string
|
||||
wantErr string
|
||||
}{
|
||||
{"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
|
||||
{"empty pin", "", "certificate pin is empty"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for pin %q", c.pin)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
|
||||
// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
|
||||
// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
|
||||
// line). Dropping it would silently disable certificate pinning whenever a proxy is set.
|
||||
func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
|
||||
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||
n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
|
||||
|
||||
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
if c == defaultNodeHTTPClient {
|
||||
t.Fatal("proxy client must not be the shared default client")
|
||||
}
|
||||
tr, ok := c.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport is %T, want *http.Transport", c.Transport)
|
||||
}
|
||||
if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
|
||||
t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
|
||||
// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
|
||||
// using system-CA verification and NOT a pin VerifyConnection.
|
||||
func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
|
||||
n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
|
||||
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPClientForNode: %v", err)
|
||||
}
|
||||
if c == defaultNodeHTTPClient {
|
||||
t.Fatal("proxy client must not be the shared default client")
|
||||
}
|
||||
if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
|
||||
t.Fatal("verify mode must not install a pin VerifyConnection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTLSConfigForNode_CurrentContract locks the pre-mTLS behavior of
|
||||
// tlsConfigForNode so the "mtls" branch added later cannot silently regress the
|
||||
// existing skip/pin modes (characterization — passes on unchanged code).
|
||||
func TestTLSConfigForNode_CurrentContract(t *testing.T) {
|
||||
t.Run("skip disables verification with no VerifyConnection", func(t *testing.T) {
|
||||
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "skip"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !cfg.InsecureSkipVerify {
|
||||
t.Fatal("skip mode must set InsecureSkipVerify")
|
||||
}
|
||||
if cfg.VerifyConnection != nil {
|
||||
t.Fatal("skip mode must not install a VerifyConnection")
|
||||
}
|
||||
})
|
||||
t.Run("pin installs a VerifyConnection", func(t *testing.T) {
|
||||
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
||||
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "pin", PinnedCertSha256: pin})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.VerifyConnection == nil {
|
||||
t.Fatal("pin mode must install a VerifyConnection")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeCertPin(t *testing.T) {
|
||||
raw := sha256.Sum256([]byte("cert"))
|
||||
hexColon := strings.ToUpper(hex.EncodeToString(raw[:]))
|
||||
// reinsert colons in openssl -fingerprint style
|
||||
var withColons strings.Builder
|
||||
for i := 0; i < len(hexColon); i += 2 {
|
||||
if i > 0 {
|
||||
withColons.WriteByte(':')
|
||||
}
|
||||
withColons.WriteString(hexColon[i : i+2])
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"base64 std", base64.StdEncoding.EncodeToString(raw[:]), false},
|
||||
{"base64 raw url", base64.RawURLEncoding.EncodeToString(raw[:]), false},
|
||||
{"hex bare", hex.EncodeToString(raw[:]), false},
|
||||
{"hex colon openssl", withColons.String(), false},
|
||||
{"empty", "", true},
|
||||
{"garbage", "not-a-pin", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := DecodeCertPin(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", c.in)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", c.in, err)
|
||||
}
|
||||
if string(got) != string(raw[:]) {
|
||||
t.Fatalf("decoded bytes mismatch for %q", c.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type wireObservation struct {
|
||||
pin string
|
||||
remoteAddr string
|
||||
}
|
||||
|
||||
func startLeafRecordingServer(t *testing.T) (*httptest.Server, *x509.CertPool, func() []wireObservation) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var seen []wireObservation
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
observation := wireObservation{remoteAddr: r.RemoteAddr}
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
sum := sha256.Sum256(r.TLS.PeerCertificates[0].Raw)
|
||||
observation.pin = hex.EncodeToString(sum[:])
|
||||
}
|
||||
mu.Lock()
|
||||
seen = append(seen, observation)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
srv.TLS = &tls.Config{ClientAuth: tls.RequestClientCert}
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(srv.Certificate())
|
||||
return srv, pool, func() []wireObservation {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result := make([]wireObservation, len(seen))
|
||||
copy(result, seen)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func pinOf(t *testing.T, cert tls.Certificate) string {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(cert.Certificate[0])
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func rotatingClientForTest(t *testing.T, roots *x509.CertPool) *http.Client {
|
||||
t.Helper()
|
||||
build := func() (idleClosingRoundTripper, error) {
|
||||
cert, err := getMasterClientCert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Transport{
|
||||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
RootCAs: roots,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
transport, err := newCredentialRotatingTransport(build)
|
||||
if err != nil {
|
||||
t.Fatalf("newCredentialRotatingTransport: %v", err)
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
func doWireRequest(t *testing.T, client *http.Client, url string) {
|
||||
t.Helper()
|
||||
response, err := client.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d want=%d", response.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRotationPresentsNewLeafOnNextConnection(t *testing.T) {
|
||||
server, roots, observations := startLeafRecordingServer(t)
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
oldPin := pinOf(t, oldCert)
|
||||
newPin := pinOf(t, newCert)
|
||||
if oldPin == newPin {
|
||||
t.Fatal("test fixture produced identical leaves")
|
||||
}
|
||||
var providerMu sync.Mutex
|
||||
current := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
providerMu.Lock()
|
||||
defer providerMu.Unlock()
|
||||
return current, nil
|
||||
})
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
client := rotatingClientForTest(t, roots)
|
||||
doWireRequest(t, client, server.URL)
|
||||
doWireRequest(t, client, server.URL)
|
||||
baseline := observations()
|
||||
if len(baseline) != 2 || baseline[0].pin != oldPin || baseline[1].pin != oldPin {
|
||||
t.Fatalf("baseline=%v", baseline)
|
||||
}
|
||||
if baseline[0].remoteAddr != baseline[1].remoteAddr {
|
||||
t.Fatalf("baseline connections differ: %v", baseline)
|
||||
}
|
||||
providerMu.Lock()
|
||||
current = newCert
|
||||
providerMu.Unlock()
|
||||
InvalidateMasterClientConnections()
|
||||
doWireRequest(t, client, server.URL)
|
||||
after := observations()
|
||||
if len(after) != 3 || after[2].pin != newPin {
|
||||
t.Fatalf("rotation observations=%v want new leaf=%s", after, newPin)
|
||||
}
|
||||
if after[2].remoteAddr == baseline[1].remoteAddr {
|
||||
t.Fatalf("rotated request reused stale connection %s", after[2].remoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRotationControlKeepsOldLeafWithoutInvalidation(t *testing.T) {
|
||||
server, roots, observations := startLeafRecordingServer(t)
|
||||
oldCert := masterCertForTest(t)
|
||||
newCert := masterCertForTest(t)
|
||||
oldPin := pinOf(t, oldCert)
|
||||
var providerMu sync.Mutex
|
||||
current := oldCert
|
||||
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
||||
providerMu.Lock()
|
||||
defer providerMu.Unlock()
|
||||
return current, nil
|
||||
})
|
||||
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
||||
client := rotatingClientForTest(t, roots)
|
||||
doWireRequest(t, client, server.URL)
|
||||
providerMu.Lock()
|
||||
current = newCert
|
||||
providerMu.Unlock()
|
||||
doWireRequest(t, client, server.URL)
|
||||
got := observations()
|
||||
if len(got) != 2 || got[1].pin != oldPin {
|
||||
t.Fatalf("control observations=%v want stale leaf=%s", got, oldPin)
|
||||
}
|
||||
if got[0].remoteAddr != got[1].remoteAddr {
|
||||
t.Fatalf("control did not reuse connection: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1575,7 +1575,6 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
|
||||
}
|
||||
return res
|
||||
}
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||
@@ -1651,7 +1650,6 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
|
||||
}
|
||||
}
|
||||
if !pushFailed {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,78 +7,8 @@ import (
|
||||
|
||||
"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/runtime"
|
||||
)
|
||||
|
||||
func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) {
|
||||
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 nodeID = 1
|
||||
const email = "node-user@example.com"
|
||||
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003"
|
||||
const wantGroup = "vip"
|
||||
const wantComment = "renewed manually"
|
||||
|
||||
id := nodeID
|
||||
central := &model.Inbound{
|
||||
UserId: 1,
|
||||
NodeID: &id,
|
||||
Tag: "n1-vless",
|
||||
Enable: true,
|
||||
Port: 20001,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"group":"` + wantGroup + `","comment":"` + wantComment + `"}]}`,
|
||||
}
|
||||
if err := db.Create(central).Error; err != nil {
|
||||
t.Fatalf("create node inbound: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.ClientRecord{
|
||||
Email: email,
|
||||
UUID: uid,
|
||||
Enable: true,
|
||||
Group: wantGroup,
|
||||
Comment: wantComment,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{
|
||||
Tag: "n1-vless",
|
||||
Enable: true,
|
||||
Port: 20001,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true}]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var row model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("lookup client row after sync: %v", err)
|
||||
}
|
||||
if row.Group != wantGroup {
|
||||
t.Errorf("group was wiped by node snapshot sync: got %q, want %q", row.Group, wantGroup)
|
||||
}
|
||||
if row.Comment != wantComment {
|
||||
t.Errorf("comment was wiped by node snapshot sync: got %q, want %q", row.Comment, wantComment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInbound_KeepsGroupWhenIncomingEmpty(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
|
||||
@@ -29,18 +29,6 @@ func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool {
|
||||
return aerr == nil && berr == nil && string(an) == string(bn)
|
||||
}
|
||||
|
||||
// advancePushedInbound advances the node's reconcile-skip fingerprint from the
|
||||
// pre-edit settings to the saved ones after every per-client push succeeded.
|
||||
func advancePushedInbound(rt runtime.Runtime, prevSettings string, ib *model.Inbound) {
|
||||
rem, ok := rt.(*runtime.Remote)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
prev := *ib
|
||||
prev.Settings = prevSettings
|
||||
rem.AdvancePushedInbound(&prev, ib)
|
||||
}
|
||||
|
||||
// delInboundClients removes several clients from a single inbound in one pass:
|
||||
// one settings rewrite, one runtime sweep, one Save and one link delta for the
|
||||
// whole batch, instead of repeating the full per-client cycle. It mirrors the
|
||||
@@ -112,7 +100,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
var sharedSet map[string]bool
|
||||
@@ -235,7 +222,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
||||
}
|
||||
}
|
||||
if nodePush && !nodePushFailed {
|
||||
advancePushedInbound(nodeRt, prevSettings, oldInbound)
|
||||
}
|
||||
|
||||
return needRestart, nil
|
||||
@@ -480,7 +466,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||
return false, err
|
||||
}
|
||||
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
// From the stamped wire entries, not from clients: created_at / updated_at /
|
||||
@@ -609,7 +594,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
||||
}
|
||||
}
|
||||
if push {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,7 +826,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
return false, nil
|
||||
}
|
||||
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
// From the stamped wire entry, not from clients[0]: created_at, the
|
||||
@@ -1021,7 +1004,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
|
||||
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
|
||||
} else {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1082,7 +1064,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||
return false, err
|
||||
}
|
||||
|
||||
prevSettings := oldInbound.Settings
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||
@@ -1191,7 +1172,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||
if err1 != nil {
|
||||
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||
} else {
|
||||
advancePushedInbound(rt, prevSettings, oldInbound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"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/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// How long a client stays recoverable after a node merge concluded it is gone.
|
||||
// Any merge that sees it attached again inside this window clears the mark.
|
||||
const syncOrphanReapGrace = 15 * time.Minute
|
||||
|
||||
// A traffic row with no clients row behind it is drift the orphan sweep will
|
||||
// never mark, so it stays on the old delete-immediately path.
|
||||
func clientRecordExists(tx *gorm.DB, email string) bool {
|
||||
var n int64
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("email = ? AND sync_orphaned_at = 0", email).
|
||||
Update("sync_orphaned_at", nowMs).Error
|
||||
}
|
||||
|
||||
// A client that is attached again is not orphaned, whatever an earlier merge
|
||||
// concluded — this is what makes a bad merge recoverable instead of fatal.
|
||||
func clearSyncOrphanMarks(tx *gorm.DB) error {
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Update("sync_orphaned_at", 0).Error
|
||||
}
|
||||
|
||||
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no
|
||||
// later merge reclaimed. It is the only path that hard-deletes for that sweep.
|
||||
func (s *ClientService) ReapSyncOrphans() (int, error) {
|
||||
db := database.GetDB()
|
||||
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
|
||||
|
||||
var emails []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
|
||||
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
reaped := 0
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
|
||||
}); err != nil {
|
||||
return reaped, err
|
||||
}
|
||||
reaped += len(batch)
|
||||
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
|
||||
t.Helper()
|
||||
var row model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("read client %q: %v", email, err)
|
||||
}
|
||||
return row.SyncOrphanedAt
|
||||
}
|
||||
|
||||
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
|
||||
t.Helper()
|
||||
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("email = ?", email).
|
||||
Update("sync_orphaned_at", past).Error; err != nil {
|
||||
t.Fatalf("backdate orphan mark: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge must soft-orphan, not delete: everything stays recoverable until
|
||||
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
|
||||
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "gone@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("merge did not stamp sync_orphaned_at")
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap inside grace: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
|
||||
}
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatal("client removed before the grace period elapsed")
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
reaped, err = clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap after grace: %v", err)
|
||||
}
|
||||
if reaped != 1 {
|
||||
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
|
||||
}
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 0 || traf != 0 {
|
||||
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// A client the node reports again was never gone: clearing the mark is what
|
||||
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
||||
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "flaky@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("setup: expected the merge to mark the client")
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
||||
|
||||
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
||||
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
|
||||
// mark and keep their existing manual-cleanup semantics.
|
||||
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
const email = "manual@x"
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
|
||||
}
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// Node country flags (item 7 Q7, resolved 2026-10-24): the public
|
||||
// views carry an ISO country code per node, which the themes render
|
||||
// as a flag. The upstream hub resolves it by calling ipinfo.io per
|
||||
// node; this build never sends a node address to a third party — the
|
||||
// lookup is a local MMDB read on the master, from the node's own
|
||||
// reported addresses. The agent is untouched: it already reports its
|
||||
// interface addresses in its facts, and the master already observes
|
||||
// the connection's source addresses (SeenIPv4/SeenIPv6).
|
||||
//
|
||||
// The data is the DB-IP Lite country database (CC BY 4.0 by DB-IP,
|
||||
// https://db-ip.com; attribution and snapshot provenance live in
|
||||
// geoipdata/NOTICE), in the MMDB conversion published by the
|
||||
// sapics/ip-location-db project. Three layers, in winning order:
|
||||
//
|
||||
// 1. a local file in the data folder (geoip-country.mmdb beside the
|
||||
// panel database) — written by the weekly updater below, or by
|
||||
// the operator by hand;
|
||||
// 2. the factory snapshot embedded in the binary (geoipdata/), so a
|
||||
// fresh install shows flags with no network at all;
|
||||
// 3. no database: lookups answer "", the views render exactly the
|
||||
// pre-flag shape.
|
||||
//
|
||||
// Reads are mmap-backed (maxminddb.Open) or run against the embedded
|
||||
// bytes in place; either way a lookup is a pure in-memory tree walk
|
||||
// on the snapshot path, and answers are cached per address for the
|
||||
// life of the loaded database.
|
||||
|
||||
//go:embed geoipdata/country.mmdb
|
||||
var geoipCountryEmbedded []byte
|
||||
|
||||
// geoipCountryFileName is the local copy's name inside the data
|
||||
// folder (config.GetDBFolderPath, where x-ui.db and probe.db live).
|
||||
const geoipCountryFileName = "geoip-country.mmdb"
|
||||
|
||||
// geoipCountryDefaultURL is the weekly updater's fallback source,
|
||||
// used when the operator has not set the geoip_country_url setting.
|
||||
//
|
||||
// RELEASE-TIME (main session): point this at the self-hosted Gitea
|
||||
// mirror asset for geoip-country.mmdb. Empty disables auto-update;
|
||||
// the embedded snapshot and any hand-placed local file still apply.
|
||||
// Set at V2.3.0 release time to the standing `geoip-country` release
|
||||
// asset on the self-hosted Gitea (asset swapped in place, URL stable).
|
||||
const geoipCountryDefaultURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/geoip-country/geoip-country.mmdb"
|
||||
|
||||
// Settings keys (main settings KV, alongside the other probe_* keys).
|
||||
const (
|
||||
geoipCountryURLSetting = "geoip_country_url"
|
||||
geoipCountryETagSetting = "geoip_country_etag"
|
||||
)
|
||||
|
||||
// geoipCountryMaxBytes caps one update download; the country
|
||||
// database is ~8 MB today, the cap only stops a runaway response.
|
||||
const geoipCountryMaxBytes = 64 << 20
|
||||
|
||||
// geoipCountryProbeIP / geoipCountryProbeIPv6 are the well-known
|
||||
// addresses a candidate database must answer to be trusted (Google
|
||||
// DNS v4, AliDNS v6 — both resolve in the DB-IP data).
|
||||
const (
|
||||
geoipCountryProbeIP = "8.8.8.8"
|
||||
geoipCountryProbeIPv6 = "2400:3200::1"
|
||||
)
|
||||
|
||||
// GeoIPCountryLocalPath is where the local country database lives.
|
||||
func GeoIPCountryLocalPath() string {
|
||||
return filepath.Join(config.GetDBFolderPath(), geoipCountryFileName)
|
||||
}
|
||||
|
||||
// geoipCountryUpdateURL resolves the updater's source: the operator
|
||||
// setting wins, else the compiled-in default (possibly empty).
|
||||
func geoipCountryUpdateURL() string {
|
||||
if v, ok := probeSetting(geoipCountryURLSetting); ok {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return geoipCountryDefaultURL
|
||||
}
|
||||
|
||||
// ---- the loaded database ----
|
||||
|
||||
// countryRecord is the DB-IP Lite record shape: a flat country_code.
|
||||
type countryRecord struct {
|
||||
CountryCode string `maxminddb:"country_code"`
|
||||
}
|
||||
|
||||
// geoipDB is one loaded database plus its per-address answer cache
|
||||
// ("" answers are cached too — an unlisted address stays unlisted
|
||||
// until the database is swapped).
|
||||
type geoipDB struct {
|
||||
reader *maxminddb.Reader
|
||||
origin string // "local" | "embedded"
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]string
|
||||
}
|
||||
|
||||
func (db *geoipDB) lookup(ipStr string) string {
|
||||
db.mu.Lock()
|
||||
if cc, ok := db.cache[ipStr]; ok {
|
||||
db.mu.Unlock()
|
||||
return cc
|
||||
}
|
||||
db.mu.Unlock()
|
||||
|
||||
cc := ""
|
||||
var rec countryRecord
|
||||
if err := db.reader.Lookup(net.ParseIP(ipStr), &rec); err == nil {
|
||||
cc = rec.CountryCode
|
||||
}
|
||||
|
||||
db.mu.Lock()
|
||||
db.cache[ipStr] = cc
|
||||
db.mu.Unlock()
|
||||
return cc
|
||||
}
|
||||
|
||||
var (
|
||||
geoipMu sync.RWMutex
|
||||
geoipCur *geoipDB
|
||||
geoipOnce sync.Once
|
||||
)
|
||||
|
||||
// currentGeoIP returns the live database, loading it on first use.
|
||||
func currentGeoIP() *geoipDB {
|
||||
geoipOnce.Do(func() {
|
||||
if db := loadGeoIP(); db != nil {
|
||||
swapGeoIP(db)
|
||||
}
|
||||
})
|
||||
geoipMu.RLock()
|
||||
defer geoipMu.RUnlock()
|
||||
return geoipCur
|
||||
}
|
||||
|
||||
// swapGeoIP makes db the live database. The replaced reader is
|
||||
// deliberately NOT closed: it is mmap-backed (or backed by the
|
||||
// binary's own image), a lookup racing the swap could still be
|
||||
// walking it, and an mmap cannot be safely unmapped under a reader.
|
||||
// The mapping is one file per week at most and is reclaimed when the
|
||||
// process exits.
|
||||
func swapGeoIP(db *geoipDB) {
|
||||
geoipMu.Lock()
|
||||
geoipCur = db
|
||||
geoipMu.Unlock()
|
||||
}
|
||||
|
||||
// loadGeoIP applies the layer order: a valid local file wins, else
|
||||
// the embedded snapshot. A local file that will not open or will not
|
||||
// answer falls through to the snapshot with one warning — a corrupt
|
||||
// download must never take the flags (or the panel) down.
|
||||
func loadGeoIP() *geoipDB {
|
||||
if path := GeoIPCountryLocalPath(); path != "" {
|
||||
if r, err := openGeoIPFile(path); err == nil {
|
||||
return &geoipDB{reader: r, origin: "local", cache: map[string]string{}}
|
||||
} else {
|
||||
logger.Warningf("geoip: local country database %s unusable (%v); using the embedded snapshot", path, err)
|
||||
}
|
||||
}
|
||||
if r, err := maxminddb.FromBytes(geoipCountryEmbedded); err == nil {
|
||||
if err := probeGeoIPReader(r); err == nil {
|
||||
return &geoipDB{reader: r, origin: "embedded", cache: map[string]string{}}
|
||||
} else {
|
||||
logger.Warningf("geoip: embedded country snapshot fails validation: %v", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warningf("geoip: embedded country snapshot will not open: %v", err)
|
||||
}
|
||||
return &geoipDB{origin: "none", cache: map[string]string{}}
|
||||
}
|
||||
|
||||
// openGeoIPFile opens a database file and checks it answers.
|
||||
func openGeoIPFile(path string) (*maxminddb.Reader, error) {
|
||||
r, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := probeGeoIPReader(r); err != nil {
|
||||
_ = r.Close()
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// probeGeoIPReader is the functional validation: the well-known
|
||||
// probe address must resolve to a country code, and a v6 probe must
|
||||
// at least answer without an error. (maxminddb's Verify is NOT used:
|
||||
// it also audits metadata niceties — the DB-IP/sapics files carry an
|
||||
// empty description map and fail it despite being fully readable,
|
||||
// verified against the factory snapshot on 2026-10-24. A candidate
|
||||
// that opens and answers both probes is a working country DB; a
|
||||
// truncated or foreign file fails to open or errors a lookup.)
|
||||
func probeGeoIPReader(r *maxminddb.Reader) error {
|
||||
var rec countryRecord
|
||||
if err := r.Lookup(net.ParseIP(geoipCountryProbeIP), &rec); err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.CountryCode == "" {
|
||||
return fmt.Errorf("probe address %s resolves to no country", geoipCountryProbeIP)
|
||||
}
|
||||
return r.Lookup(net.ParseIP(geoipCountryProbeIPv6), &rec)
|
||||
}
|
||||
|
||||
// verifyGeoIPFile validates a fresh download in place before it may
|
||||
// replace the live database.
|
||||
func verifyGeoIPFile(path string) error {
|
||||
r, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
return probeGeoIPReader(r)
|
||||
}
|
||||
|
||||
// ---- lookups used by the public views ----
|
||||
|
||||
// countryCodeForIP resolves one public IP literal to its ISO country
|
||||
// code, "" for anything non-public, unknown, or unanswerable. The
|
||||
// public gate runs first so private/held addresses never reach the
|
||||
// database (they have no country, exactly as on the hub).
|
||||
func countryCodeForIP(ipStr string) string {
|
||||
ipStr = strings.TrimSpace(ipStr)
|
||||
if !IsPublicIPLiteral(ipStr) {
|
||||
return ""
|
||||
}
|
||||
db := currentGeoIP()
|
||||
if db == nil || db.reader == nil {
|
||||
return ""
|
||||
}
|
||||
return db.lookup(ipStr)
|
||||
}
|
||||
|
||||
// probeCountryAuto is a node's auto-detected country: the first
|
||||
// public address in the hub's order — the held interface v4, then
|
||||
// held v6, then the master-observed exit v4, then exit v6 — resolved
|
||||
// locally. "" when the node shows no public address or the database
|
||||
// has no answer for it.
|
||||
func probeCountryAuto(a *model.AgentNode) string {
|
||||
for _, s := range []string{a.IPv4, a.IPv6, a.SeenIPv4, a.SeenIPv6} {
|
||||
if IsPublicIPLiteral(s) {
|
||||
return countryCodeForIP(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeCountry resolves the pair the views publish (hub api.rs): the
|
||||
// effective country is the operator's pin when one is set, else the
|
||||
// auto-detected code; country_auto always carries the detected code.
|
||||
func probeCountry(pin string, a *model.AgentNode) (country, auto string) {
|
||||
auto = probeCountryAuto(a)
|
||||
if pin != "" {
|
||||
return pin, auto
|
||||
}
|
||||
return auto, auto
|
||||
}
|
||||
|
||||
// ---- weekly auto-update ----
|
||||
|
||||
type geoipFetchResult int
|
||||
|
||||
const (
|
||||
geoipFetchUnchanged geoipFetchResult = iota
|
||||
geoipFetchUpdated
|
||||
)
|
||||
|
||||
// downloadGeoIPCountry performs one conditional download of url into
|
||||
// destPath. The candidate lands in a temp file in the destination
|
||||
// directory, is fully validated there, and only then renames over
|
||||
// destPath (atomic on the same filesystem); any failure leaves
|
||||
// destPath and the live database untouched.
|
||||
func downloadGeoIPCountry(client *http.Client, url, destPath, etag string, ifModifiedSince time.Time) (geoipFetchResult, string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
if !ifModifiedSince.IsZero() {
|
||||
req.Header.Set("If-Modified-Since", ifModifiedSince.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return geoipFetchUnchanged, etag, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("update server answered %s", resp.Status)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(destPath)
|
||||
tmp, err := os.CreateTemp(dir, ".geoip-country-*.tmp")
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
n, err := io.Copy(tmp, io.LimitReader(resp.Body, geoipCountryMaxBytes))
|
||||
if err := tmp.Close(); err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if n == 0 {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("update download was empty")
|
||||
}
|
||||
if err := verifyGeoIPFile(tmpName); err != nil {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("downloaded database rejected: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, destPath); err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
return geoipFetchUpdated, resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// UpdateGeoIPCountry runs one update pass: a conditional download
|
||||
// from the configured URL, a validated atomic replace of the local
|
||||
// copy, and a hot swap of the live reader. No configured URL or any
|
||||
// failure keeps the current database; a failure logs a single
|
||||
// warning (the hub discipline: a broken mirror must not spam).
|
||||
func UpdateGeoIPCountry() {
|
||||
url := geoipCountryUpdateURL()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
dest := GeoIPCountryLocalPath()
|
||||
var lastMod time.Time
|
||||
if fi, err := os.Stat(dest); err == nil {
|
||||
lastMod = fi.ModTime()
|
||||
}
|
||||
etag, _ := probeSetting(geoipCountryETagSetting)
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
res, newETag, err := downloadGeoIPCountry(client, url, dest, etag, lastMod)
|
||||
if err != nil {
|
||||
logger.Warningf("geoip: country database update from %s failed: %v (keeping the current database)", url, err)
|
||||
return
|
||||
}
|
||||
if res != geoipFetchUpdated {
|
||||
return
|
||||
}
|
||||
if newETag != "" && newETag != etag {
|
||||
_ = setProbeSetting(geoipCountryETagSetting, newETag)
|
||||
}
|
||||
r, err := openGeoIPFile(dest)
|
||||
if err != nil {
|
||||
logger.Warningf("geoip: updated country database %s will not reopen: %v", dest, err)
|
||||
return
|
||||
}
|
||||
swapGeoIP(&geoipDB{reader: r, origin: "local", cache: map[string]string{}})
|
||||
logger.Infof("geoip: country database updated from %s", url)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user