- probe package: probe_ping_task/probe_ping_node tables; task CRUD with hub save semantics (id high-water, base-diff assignment, 64-per-agent crowding refusal, auto_join cap); filing with assignment-gated insert; PingHistory weighted-median chart read over minute+hour tiers; auto-join on first Record (ensureRows). - controller: /panel/api/agent/probe-tasks* management endpoints (node id space, hub validation chain verbatim); series=ping now serves real rows from probe.PingHistory; heartbeat ingests probeResults (missing latency skipped, batch capped); config response carries probe_tasks (always present, empty = clear). - agent: per-task probe loops (immediate first probe, ticker, 900ms resolve/dial budgets, <=3 addresses, resolve overrun = no sample), result buffer drained on heartbeat and requeued on failure; task set applied before the config-hash early return.
897 lines
28 KiB
Go
897 lines
28 KiB
Go
package controller
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"net"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||
"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/selfupdate"
|
||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// AgentController serves the lightweight agent API.
|
||
//
|
||
// Agent endpoints (register/heartbeat/config/traffic) authenticate with the
|
||
// agent's own Bearer token, which is NOT a panel API token — they are mounted
|
||
// on a public route group and must never go through the panel auth middleware.
|
||
// The panel-authed administration endpoints (token issuance, egress
|
||
// management) live under the authenticated /panel/api group instead.
|
||
type AgentController struct {
|
||
xrayService service.XrayService
|
||
inboundService service.InboundService
|
||
}
|
||
|
||
func NewAgentController(g *gin.RouterGroup) *AgentController {
|
||
a := &AgentController{}
|
||
a.initRouter(g)
|
||
return a
|
||
}
|
||
|
||
func (a *AgentController) initRouter(g *gin.RouterGroup) {
|
||
g.POST("/register", a.register)
|
||
g.POST("/heartbeat", a.heartbeat)
|
||
g.GET("/config", a.getConfig)
|
||
g.POST("/traffic", a.reportTraffic)
|
||
}
|
||
|
||
// RegisterAdminRoutes mounts the panel-authenticated administration endpoints
|
||
// on an already-authed route group.
|
||
func (a *AgentController) RegisterAdminRoutes(g *gin.RouterGroup) {
|
||
g.POST("/tokens", a.createToken)
|
||
g.GET("/tokens", a.listTokens)
|
||
g.DELETE("/tokens/:id", a.deleteToken)
|
||
g.GET("/egress/:token", a.getEgress)
|
||
g.PUT("/egress/:token", a.setEgress)
|
||
g.GET("/install/:nodeId", a.getAgentInstall)
|
||
}
|
||
|
||
func hashAgentToken(token string) string {
|
||
sum := sha256.Sum256([]byte(token))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
var errRequest = errors.New("bad request")
|
||
|
||
func bearerToken(c *gin.Context) string {
|
||
auth := c.GetHeader("Authorization")
|
||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||
return auth[7:]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
|
||
if token == "" {
|
||
return nil, false
|
||
}
|
||
var agent model.AgentNode
|
||
if err := database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error; err != nil {
|
||
return nil, false
|
||
}
|
||
// Item 19 backfill: the token has just passed the hash check, so for
|
||
// rows created before plaintext storage existed, adopt the presented
|
||
// token as the recoverable copy. The guarded update only fires while
|
||
// token_plain is unset — an authenticated request can never overwrite
|
||
// an already-stored token (only issuance writes a fresh one). "Unset"
|
||
// must cover SQL NULL as well as '': AutoMigrate added the column to
|
||
// pre-existing rows as NULL, and NULL = '' is never true, so an
|
||
// equality-only guard silently matched zero legacy rows in
|
||
// production (the GORM-scanned value reads as "" either way, which is
|
||
// why the in-memory check above cannot distinguish them).
|
||
if agent.TokenPlain == "" {
|
||
if res := database.GetDB().Model(&model.AgentNode{}).
|
||
Where("id = ? AND (token_plain IS NULL OR token_plain = '')", agent.Id).
|
||
Update("token_plain", token); res.Error == nil && res.RowsAffected > 0 {
|
||
agent.TokenPlain = token
|
||
}
|
||
}
|
||
return &agent, true
|
||
}
|
||
|
||
// requestSourceIP extracts the real source IP of an agent request.
|
||
// Agents reach the master through Cloudflare, so the TCP peer is a CF
|
||
// edge and the agent's own address travels in CF-Connecting-IP; the
|
||
// first X-Forwarded-For hop is the next best signal, and the transport
|
||
// RemoteAddr is the direct-connection fallback. Only literal IPs are
|
||
// accepted — anything unparsable falls through to the next source.
|
||
func requestSourceIP(c *gin.Context) net.IP {
|
||
var candidates []string
|
||
if v := c.GetHeader("CF-Connecting-IP"); v != "" {
|
||
candidates = append(candidates, v)
|
||
}
|
||
if v := c.GetHeader("X-Forwarded-For"); v != "" {
|
||
candidates = append(candidates, strings.Split(v, ",")[0])
|
||
}
|
||
if host, _, err := net.SplitHostPort(c.Request.RemoteAddr); err == nil {
|
||
candidates = append(candidates, host)
|
||
} else {
|
||
candidates = append(candidates, c.Request.RemoteAddr)
|
||
}
|
||
for _, cand := range candidates {
|
||
if ip := net.ParseIP(strings.TrimSpace(cand)); ip != nil {
|
||
return ip
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// captureSeenIP records the source address of an authenticated agent
|
||
// request on the agent row (SeenIPv4/SeenIPv6). This is the master's
|
||
// view of the agent's public side: for a NAT'd agent it is the only
|
||
// place the public address ever appears. Writes are change-only —
|
||
// traffic reports land every ~10s and the address almost never moves,
|
||
// so an unchanged address must not turn into a DB write per request.
|
||
func (a *AgentController) captureSeenIP(c *gin.Context, agent *model.AgentNode) {
|
||
ip := requestSourceIP(c)
|
||
if ip == nil || agent == nil {
|
||
return
|
||
}
|
||
updates := map[string]any{"seen_ip_at": time.Now().UnixMilli()}
|
||
if v4 := ip.To4(); v4 != nil {
|
||
if agent.SeenIPv4 == v4.String() {
|
||
return
|
||
}
|
||
updates["seen_ipv4"] = v4.String()
|
||
} else {
|
||
if agent.SeenIPv6 == ip.String() {
|
||
return
|
||
}
|
||
updates["seen_ipv6"] = ip.String()
|
||
}
|
||
if err := database.GetDB().Model(&model.AgentNode{}).
|
||
Where("id = ?", agent.Id).Updates(updates).Error; err != nil {
|
||
return
|
||
}
|
||
if v, ok := updates["seen_ipv4"]; ok {
|
||
agent.SeenIPv4 = v.(string)
|
||
}
|
||
if v, ok := updates["seen_ipv6"]; ok {
|
||
agent.SeenIPv6 = v.(string)
|
||
}
|
||
agent.SeenIPAt = updates["seen_ip_at"].(int64)
|
||
}
|
||
|
||
func (a *AgentController) touchAgent(id uint, name string) {
|
||
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
|
||
if name != "" {
|
||
updates["name"] = name
|
||
}
|
||
db := database.GetDB()
|
||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// register activates a pre-issued token. Tokens are issued bound to a node via
|
||
// POST /panel/api/agent/tokens; the agent only claims its token here.
|
||
func (a *AgentController) register(c *gin.Context) {
|
||
var req struct {
|
||
Token string `json:"token"`
|
||
Name string `json:"name"`
|
||
ProbeFacts *heartbeatProbeFacts `json:"probeFacts"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||
jsonMsg(c, "Token required", errRequest)
|
||
return
|
||
}
|
||
agent, ok := a.findAgent(req.Token)
|
||
if !ok {
|
||
c.JSON(401, gin.H{"message": "unknown token"})
|
||
return
|
||
}
|
||
a.captureSeenIP(c, agent)
|
||
a.touchAgent(uint(agent.Id), req.Name)
|
||
if req.ProbeFacts != nil {
|
||
a.updateAgentProbe(uint(agent.Id), req.ProbeFacts, nil)
|
||
}
|
||
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
|
||
jsonObj(c, gin.H{"message": "registered"}, nil)
|
||
}
|
||
|
||
// heartbeatTelemetry is the optional telemetry object an agent piggybacks
|
||
// on its heartbeat (P13). Every field is a pointer: a missing field means
|
||
// "not reported this round" and must not overwrite the stored value. Older
|
||
// agents send no telemetry object at all and are handled exactly as before.
|
||
type heartbeatTelemetry struct {
|
||
AgentVersion *string `json:"agentVersion"`
|
||
XrayVersion *string `json:"xrayVersion"`
|
||
UptimeSeconds *uint64 `json:"uptimeSeconds"`
|
||
LatencyMs *int `json:"latencyMs"`
|
||
CpuPercent *float64 `json:"cpuPercent"`
|
||
MemPercent *float64 `json:"memPercent"`
|
||
IPv4 *string `json:"ipv4"`
|
||
IPv6 *string `json:"ipv6"`
|
||
}
|
||
|
||
// 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()}
|
||
if t.AgentVersion != nil {
|
||
updates["agent_version"] = *t.AgentVersion
|
||
}
|
||
if t.XrayVersion != nil {
|
||
updates["xray_version"] = *t.XrayVersion
|
||
}
|
||
if t.UptimeSeconds != nil {
|
||
updates["uptime_secs"] = *t.UptimeSeconds
|
||
}
|
||
if t.LatencyMs != nil {
|
||
updates["latency_ms"] = *t.LatencyMs
|
||
}
|
||
if t.CpuPercent != nil {
|
||
updates["cpu_pct"] = *t.CpuPercent
|
||
}
|
||
if t.MemPercent != nil {
|
||
updates["mem_pct"] = *t.MemPercent
|
||
}
|
||
if t.IPv4 != nil {
|
||
updates["ipv4"] = *t.IPv4
|
||
}
|
||
if t.IPv6 != nil {
|
||
updates["ipv6"] = *t.IPv6
|
||
}
|
||
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||
}
|
||
|
||
// heartbeatProbeFacts / heartbeatProbeMetrics are the monitor-probe-style
|
||
// host snapshot an agent piggybacks on register and heartbeat (item 7 B1).
|
||
// Every field is a pointer for the same reason as telemetry: an absent
|
||
// field means "not reported" and must not overwrite the stored value, so
|
||
// old agents (no probe block at all) and partial reports both merge
|
||
// cleanly. JSON names must match internal/agent's ProbeFacts/ProbeMetrics.
|
||
type heartbeatProbeFacts struct {
|
||
Hostname *string `json:"hostname"`
|
||
OS *string `json:"os"`
|
||
Kernel *string `json:"kernel"`
|
||
Arch *string `json:"arch"`
|
||
Virt *string `json:"virt"`
|
||
CPUName *string `json:"cpuName"`
|
||
CPUCores *uint32 `json:"cpuCores"`
|
||
MemTotal *uint64 `json:"memTotal"`
|
||
SwapTotal *uint64 `json:"swapTotal"`
|
||
DiskTotal *uint64 `json:"diskTotal"`
|
||
}
|
||
|
||
type heartbeatProbeMetrics struct {
|
||
BootID *string `json:"bootId"`
|
||
Iface *string `json:"iface"`
|
||
Uptime *uint64 `json:"uptime"`
|
||
Load *[3]float32 `json:"load"`
|
||
MemTotal *uint64 `json:"memTotal"`
|
||
MemUsed *uint64 `json:"memUsed"`
|
||
SwapTotal *uint64 `json:"swapTotal"`
|
||
SwapUsed *uint64 `json:"swapUsed"`
|
||
DiskTotal *uint64 `json:"diskTotal"`
|
||
DiskUsed *uint64 `json:"diskUsed"`
|
||
NetRxTotal *uint64 `json:"netRxTotal"`
|
||
NetTxTotal *uint64 `json:"netTxTotal"`
|
||
NetRx *uint64 `json:"netRx"`
|
||
NetTx *uint64 `json:"netTx"`
|
||
TCP *uint32 `json:"tcp"`
|
||
UDP *uint32 `json:"udp"`
|
||
Procs *uint32 `json:"procs"`
|
||
CPU *float32 `json:"cpu"`
|
||
}
|
||
|
||
// updateAgentProbe persists the reported probe facts/metrics onto the agent
|
||
// row. Only fields actually present in the report are written. Facts and
|
||
// 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{}
|
||
if f != nil {
|
||
if f.Hostname != nil {
|
||
updates["probe_hostname"] = *f.Hostname
|
||
}
|
||
if f.OS != nil {
|
||
updates["probe_os"] = *f.OS
|
||
}
|
||
if f.Kernel != nil {
|
||
updates["probe_kernel"] = *f.Kernel
|
||
}
|
||
if f.Arch != nil {
|
||
updates["probe_arch"] = *f.Arch
|
||
}
|
||
if f.Virt != nil {
|
||
updates["probe_virt"] = *f.Virt
|
||
}
|
||
if f.CPUName != nil {
|
||
updates["probe_cpu_name"] = *f.CPUName
|
||
}
|
||
if f.CPUCores != nil {
|
||
updates["probe_cpu_cores"] = *f.CPUCores
|
||
}
|
||
if f.MemTotal != nil {
|
||
updates["probe_mem_total"] = *f.MemTotal
|
||
}
|
||
if f.SwapTotal != nil {
|
||
updates["probe_swap_total"] = *f.SwapTotal
|
||
}
|
||
if f.DiskTotal != nil {
|
||
updates["probe_disk_total"] = *f.DiskTotal
|
||
}
|
||
}
|
||
if m != nil {
|
||
updates["probe_at"] = time.Now().UnixMilli()
|
||
if m.BootID != nil {
|
||
updates["probe_boot_id"] = *m.BootID
|
||
}
|
||
if m.Iface != nil {
|
||
updates["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]
|
||
}
|
||
if m.MemTotal != nil {
|
||
updates["probe_mem_total"] = *m.MemTotal
|
||
}
|
||
if m.MemUsed != nil {
|
||
updates["probe_mem_used"] = *m.MemUsed
|
||
}
|
||
if m.SwapTotal != nil {
|
||
updates["probe_swap_total"] = *m.SwapTotal
|
||
}
|
||
if m.SwapUsed != nil {
|
||
updates["probe_swap_used"] = *m.SwapUsed
|
||
}
|
||
if m.DiskTotal != nil {
|
||
updates["probe_disk_total"] = *m.DiskTotal
|
||
}
|
||
if m.DiskUsed != nil {
|
||
updates["probe_disk_used"] = *m.DiskUsed
|
||
}
|
||
if m.NetRxTotal != nil {
|
||
updates["probe_net_rx_tot"] = *m.NetRxTotal
|
||
}
|
||
if m.NetTxTotal != nil {
|
||
updates["probe_net_tx_tot"] = *m.NetTxTotal
|
||
}
|
||
if m.NetRx != nil {
|
||
updates["probe_net_rx"] = *m.NetRx
|
||
}
|
||
if m.NetTx != nil {
|
||
updates["probe_net_tx"] = *m.NetTx
|
||
}
|
||
if m.TCP != nil {
|
||
updates["probe_tcp"] = *m.TCP
|
||
}
|
||
if m.UDP != nil {
|
||
updates["probe_udp"] = *m.UDP
|
||
}
|
||
if m.Procs != nil {
|
||
updates["probe_procs"] = *m.Procs
|
||
}
|
||
if m.CPU != nil {
|
||
updates["probe_cpu"] = *m.CPU
|
||
}
|
||
}
|
||
if len(updates) == 0 {
|
||
return
|
||
}
|
||
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||
}
|
||
|
||
// probeSample flattens a heartbeat metrics block into the probe
|
||
// package's Sample. Absent fields become zero values — the agent sends
|
||
// a full block whenever it sends one, and the probe store treats the
|
||
// block's presence (not per-field presence) as the signal, mirroring
|
||
// the hub's report frames.
|
||
func probeSample(m *heartbeatProbeMetrics) probe.Sample {
|
||
var s probe.Sample
|
||
if m.BootID != nil {
|
||
s.BootID = *m.BootID
|
||
}
|
||
if m.Uptime != nil {
|
||
s.Uptime = *m.Uptime
|
||
}
|
||
if m.CPU != nil {
|
||
s.CPU = *m.CPU
|
||
}
|
||
if m.MemUsed != nil {
|
||
s.MemUsed = *m.MemUsed
|
||
}
|
||
if m.SwapUsed != nil {
|
||
s.SwapUsed = *m.SwapUsed
|
||
}
|
||
if m.DiskUsed != nil {
|
||
s.DiskUsed = *m.DiskUsed
|
||
}
|
||
if m.NetRxTotal != nil {
|
||
s.NetRxTotal = *m.NetRxTotal
|
||
}
|
||
if m.NetTxTotal != nil {
|
||
s.NetTxTotal = *m.NetTxTotal
|
||
}
|
||
if m.NetRx != nil {
|
||
s.NetRx = *m.NetRx
|
||
}
|
||
if m.NetTx != nil {
|
||
s.NetTx = *m.NetTx
|
||
}
|
||
if m.TCP != nil {
|
||
s.TCP = *m.TCP
|
||
}
|
||
if m.UDP != nil {
|
||
s.UDP = *m.UDP
|
||
}
|
||
if m.Procs != nil {
|
||
s.Procs = *m.Procs
|
||
}
|
||
return s
|
||
}
|
||
|
||
// heartbeatUpdateStatus is the agent's report about a one-click update
|
||
// intent (item 20), piggybacked on the heartbeat like telemetry.
|
||
type heartbeatUpdateStatus struct {
|
||
RequestedAt int64 `json:"requestedAt"`
|
||
State string `json:"state"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// applyAgentUpdateStatus folds an agent's update report into the stored
|
||
// intent. Reports for a superseded intent (requested_at mismatch while an
|
||
// intent is pending) are ignored. Terminal reports (failed / current)
|
||
// 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 {
|
||
return
|
||
}
|
||
if row.UpdateRequestedAt == 0 || st.RequestedAt != row.UpdateRequestedAt {
|
||
return
|
||
}
|
||
updates := map[string]any{"update_state_at": time.Now().UnixMilli()}
|
||
switch st.State {
|
||
case "downloading", "swapped":
|
||
updates["update_state"] = st.State
|
||
updates["update_error"] = ""
|
||
case "failed":
|
||
updates["update_state"] = "failed"
|
||
updates["update_error"] = st.Error
|
||
updates["update_requested_at"] = int64(0)
|
||
case "current":
|
||
updates["update_state"] = "current"
|
||
updates["update_error"] = ""
|
||
updates["update_requested_at"] = int64(0)
|
||
default:
|
||
return
|
||
}
|
||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||
}
|
||
|
||
// confirmAgentUpdate marks a pending intent successful once the agent
|
||
// reports an A-series version at or beyond the intent's target — the new
|
||
// version appearing is the end-to-end success signal of the update.
|
||
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 {
|
||
return
|
||
}
|
||
if row.UpdateRequestedAt == 0 || row.UpdateTarget == "" {
|
||
return
|
||
}
|
||
if cmp, ok := config.CompareDisplayVersion(reportedVersion, row.UpdateTarget); !ok || cmp < 0 {
|
||
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
|
||
}
|
||
|
||
// heartbeatProbeResult is one latency sample an agent piggybacks on
|
||
// its heartbeat (item 7 B4). LatencyMs is a pointer: an absent field
|
||
// means "no sample" (a resolution overrun produces none) and the row
|
||
// is skipped, while a present negative value is a genuine recorded
|
||
// miss — the two must never collapse into each other.
|
||
type heartbeatProbeResult struct {
|
||
TaskID int64 `json:"taskId"`
|
||
LatencyMs *int64 `json:"latencyMs"`
|
||
}
|
||
|
||
// maxProbeResultsPerHeartbeat caps one heartbeat's probe batch. The
|
||
// agent's buffer holds far less than this in practice (64 tasks × 60 s
|
||
// at the fastest legal interval is under a thousand samples); the cap
|
||
// exists so a buggy or hostile reporter cannot make one request file
|
||
// an unbounded batch.
|
||
const maxProbeResultsPerHeartbeat = 2048
|
||
|
||
func (a *AgentController) heartbeat(c *gin.Context) {
|
||
agent, ok := a.findAgent(bearerToken(c))
|
||
if !ok {
|
||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||
return
|
||
}
|
||
a.captureSeenIP(c, agent)
|
||
// Telemetry is optional and best-effort: a body that fails to parse
|
||
// (or no body at all) must never fail the heartbeat itself.
|
||
var req struct {
|
||
Telemetry *heartbeatTelemetry `json:"telemetry"`
|
||
UpdateStatus *heartbeatUpdateStatus `json:"updateStatus"`
|
||
ProbeFacts *heartbeatProbeFacts `json:"probeFacts"`
|
||
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
|
||
ProbeResults []heartbeatProbeResult `json:"probeResults"`
|
||
}
|
||
_ = c.ShouldBindJSON(&req)
|
||
// The last-seen stamp as it stood before this heartbeat: the probe
|
||
// ledger's unseen-boot rule measures a reboot against it, so it
|
||
// must be captured before touchAgent overwrites it.
|
||
prevLastSeen := agent.LastSeen
|
||
a.touchAgent(uint(agent.Id), "")
|
||
reportedVersion := agent.AgentVersion
|
||
if req.ProbeFacts != nil || req.ProbeMetrics != nil {
|
||
a.updateAgentProbe(uint(agent.Id), req.ProbeFacts, req.ProbeMetrics)
|
||
}
|
||
if req.ProbeMetrics != nil {
|
||
// Item 7 B2: file the sample into probe.db history + the
|
||
// whole-machine traffic ledger. Best-effort by design — the
|
||
// probe package logs and swallows its own failures.
|
||
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
|
||
}
|
||
if len(req.ProbeResults) > 0 {
|
||
// Item 7 B4: file the latency batch, stamped at arrival (hub
|
||
// semantics). Results ride the heartbeat rather than the
|
||
// traffic report on purpose: probing must keep working while
|
||
// xray is down, and the traffic report early-returns then.
|
||
results := make([]probe.PingResult, 0, len(req.ProbeResults))
|
||
for _, r := range req.ProbeResults {
|
||
if r.TaskID <= 0 || r.LatencyMs == nil {
|
||
continue
|
||
}
|
||
results = append(results, probe.PingResult{TaskID: r.TaskID, LatencyMs: *r.LatencyMs})
|
||
if len(results) >= maxProbeResultsPerHeartbeat {
|
||
break
|
||
}
|
||
}
|
||
probe.InsertPings(int64(agent.Id), results)
|
||
}
|
||
if req.Telemetry != nil {
|
||
a.updateAgentTelemetry(uint(agent.Id), req.Telemetry)
|
||
if req.Telemetry.AgentVersion != nil && *req.Telemetry.AgentVersion != "" {
|
||
reportedVersion = *req.Telemetry.AgentVersion
|
||
}
|
||
}
|
||
if req.UpdateStatus != nil {
|
||
a.applyAgentUpdateStatus(uint(agent.Id), req.UpdateStatus)
|
||
}
|
||
a.confirmAgentUpdate(uint(agent.Id), reportedVersion)
|
||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||
}
|
||
|
||
// getConfig returns the full xray config for the agent's bound node.
|
||
func (a *AgentController) getConfig(c *gin.Context) {
|
||
agent, ok := a.findAgent(bearerToken(c))
|
||
if !ok {
|
||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||
return
|
||
}
|
||
a.captureSeenIP(c, agent)
|
||
if agent.NodeID == nil {
|
||
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
|
||
return
|
||
}
|
||
cfg, err := a.xrayService.GetXrayConfigForAgent(*agent.NodeID, agent.Outbounds, agent.RoutingRules)
|
||
if err != nil {
|
||
jsonMsg(c, "Failed to build xray config", err)
|
||
return
|
||
}
|
||
raw, err := json.Marshal(cfg)
|
||
if err != nil {
|
||
jsonMsg(c, "Failed to encode xray config", err)
|
||
return
|
||
}
|
||
sum := sha256.Sum256(raw)
|
||
payload := gin.H{
|
||
"config_hash": hex.EncodeToString(sum[:]),
|
||
"xray_config": json.RawMessage(raw),
|
||
// Item 7 B4: the agent's probe task set rides the existing
|
||
// config poll (no new channel). Always present from this
|
||
// master version — an empty list means "run no probes", while
|
||
// an absent field (older master) leaves the agent's current
|
||
// set untouched.
|
||
"probe_tasks": probe.PingTasksFor(int64(agent.Id)),
|
||
}
|
||
// Item 20: a pending one-click update intent rides along on the config
|
||
// poll (no new channel — the agent already polls this endpoint).
|
||
if agent.UpdateRequestedAt > 0 &&
|
||
(agent.UpdateState == "pending" || agent.UpdateState == "downloading") {
|
||
payload["agent_update"] = gin.H{
|
||
"requested_at": agent.UpdateRequestedAt,
|
||
"target": agent.UpdateTarget,
|
||
"tag": agent.UpdateTag,
|
||
"base_url": selfupdate.BaseURL(),
|
||
}
|
||
}
|
||
c.JSON(200, payload)
|
||
}
|
||
|
||
type agentTrafficReport struct {
|
||
Inbounds []struct {
|
||
Tag string `json:"tag"`
|
||
Up int64 `json:"up"`
|
||
Down int64 `json:"down"`
|
||
} `json:"inbounds"`
|
||
Users []struct {
|
||
Email string `json:"email"`
|
||
Up int64 `json:"up"`
|
||
Down int64 `json:"down"`
|
||
} `json:"users"`
|
||
// Onlines lists client emails with at least one live connection.
|
||
Onlines []string `json:"onlines"`
|
||
}
|
||
|
||
// reportTraffic ingests per-poll traffic deltas from the agent. Inbound deltas
|
||
// land on the agent's node inbound rows; client deltas reuse the shared
|
||
// email-keyed write path (plus its lifecycle maintenance, which also drives
|
||
// config changes the agent picks up on its next poll).
|
||
func (a *AgentController) reportTraffic(c *gin.Context) {
|
||
agent, ok := a.findAgent(bearerToken(c))
|
||
if !ok {
|
||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||
return
|
||
}
|
||
a.captureSeenIP(c, agent)
|
||
var req agentTrafficReport
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
jsonMsg(c, "Invalid request", err)
|
||
return
|
||
}
|
||
db := database.GetDB()
|
||
if agent.NodeID != nil {
|
||
for _, ib := range req.Inbounds {
|
||
if ib.Tag == "" || ib.Up+ib.Down <= 0 {
|
||
continue
|
||
}
|
||
_ = db.Model(&model.Inbound{}).
|
||
Where("tag = ? AND node_id = ?", ib.Tag, *agent.NodeID).
|
||
Updates(map[string]any{
|
||
"up": gorm.Expr(database.ClampedAddExpr("up"), ib.Up),
|
||
"down": gorm.Expr(database.ClampedAddExpr("down"), ib.Down),
|
||
}).Error
|
||
}
|
||
}
|
||
var clientTraffics []*xray.ClientTraffic
|
||
for _, u := range req.Users {
|
||
if u.Email == "" || u.Up+u.Down <= 0 {
|
||
continue
|
||
}
|
||
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
|
||
Email: u.Email,
|
||
Up: u.Up,
|
||
Down: u.Down,
|
||
})
|
||
}
|
||
if len(clientTraffics) > 0 {
|
||
if _, _, err := a.inboundService.AddTraffic(nil, clientTraffics); err != nil {
|
||
jsonMsg(c, "Failed to store traffic", err)
|
||
return
|
||
}
|
||
}
|
||
if agent.NodeID != nil {
|
||
a.inboundService.SetAgentOnlineClients(*agent.NodeID, req.Onlines)
|
||
}
|
||
a.touchAgent(uint(agent.Id), "")
|
||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||
}
|
||
|
||
// issueAgentToken generates a new agent token bound to nodeID. The hash is
|
||
// what authenticates; the raw token is also kept recoverably (TokenPlain,
|
||
// item 19) so the node edit page can re-display the install command.
|
||
func issueAgentToken(nodeID *int, name string) (string, int, error) {
|
||
raw := make([]byte, 32)
|
||
if _, err := rand.Read(raw); err != nil {
|
||
return "", 0, err
|
||
}
|
||
token := hex.EncodeToString(raw)
|
||
agent := model.AgentNode{
|
||
TokenHash: hashAgentToken(token),
|
||
TokenPrefix: token[:12],
|
||
TokenPlain: token,
|
||
Name: name,
|
||
NodeID: nodeID,
|
||
}
|
||
if err := database.GetDB().Create(&agent).Error; err != nil {
|
||
return "", 0, err
|
||
}
|
||
return token, agent.Id, nil
|
||
}
|
||
|
||
// createToken issues a new agent token bound to a node. The raw token is
|
||
// returned in this response and kept recoverably for the install command.
|
||
func (a *AgentController) createToken(c *gin.Context) {
|
||
var req struct {
|
||
NodeID *int `json:"node_id"`
|
||
Name string `json:"name"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
jsonMsg(c, "Invalid request", err)
|
||
return
|
||
}
|
||
if req.NodeID != nil {
|
||
var node model.Node
|
||
if err := database.GetDB().First(&node, *req.NodeID).Error; err != nil {
|
||
jsonMsg(c, "Node not found", errRequest)
|
||
return
|
||
}
|
||
}
|
||
token, _, err := issueAgentToken(req.NodeID, req.Name)
|
||
if err != nil {
|
||
jsonMsg(c, "Failed to generate token", err)
|
||
return
|
||
}
|
||
var agent model.AgentNode
|
||
_ = database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error
|
||
jsonObj(c, gin.H{
|
||
"id": agent.Id,
|
||
"token": token,
|
||
"token_prefix": agent.TokenPrefix,
|
||
"node_id": agent.NodeID,
|
||
"name": agent.Name,
|
||
}, nil)
|
||
}
|
||
|
||
// getAgentInstall returns what the node edit page needs to re-display the
|
||
// full install command for an agent node (item 19): the agent's name and,
|
||
// when the master holds a recoverable copy, its raw token. Legacy rows
|
||
// whose token has not been backfilled yet answer hasToken=false with an
|
||
// empty token — the UI then shows the command without the token and points
|
||
// at /etc/x-ui/agent.json on the node itself.
|
||
func (a *AgentController) getAgentInstall(c *gin.Context) {
|
||
nodeID, err := strconv.Atoi(c.Param("nodeId"))
|
||
if err != nil {
|
||
jsonMsg(c, "Invalid node id", errRequest)
|
||
return
|
||
}
|
||
var agent model.AgentNode
|
||
if err := database.GetDB().Where("node_id = ?", nodeID).
|
||
Order("last_seen DESC, id DESC").First(&agent).Error; err != nil {
|
||
jsonMsg(c, "Agent not found for node", errRequest)
|
||
return
|
||
}
|
||
jsonObj(c, gin.H{
|
||
"nodeId": nodeID,
|
||
"name": agent.Name,
|
||
"token": agent.TokenPlain,
|
||
"hasToken": agent.TokenPlain != "",
|
||
}, nil)
|
||
}
|
||
|
||
func (a *AgentController) listTokens(c *gin.Context) {
|
||
var agents []model.AgentNode
|
||
_ = database.GetDB().Order("id DESC").Find(&agents).Error
|
||
out := make([]gin.H, 0, len(agents))
|
||
for _, ag := range agents {
|
||
out = append(out, gin.H{
|
||
"id": ag.Id,
|
||
"token_prefix": ag.TokenPrefix,
|
||
"name": ag.Name,
|
||
"node_id": ag.NodeID,
|
||
"last_seen": ag.LastSeen,
|
||
"has_egress": ag.Outbounds != "" || ag.RoutingRules != "",
|
||
"created_at": ag.CreatedAt,
|
||
})
|
||
}
|
||
jsonObj(c, out, nil)
|
||
}
|
||
|
||
func (a *AgentController) deleteToken(c *gin.Context) {
|
||
_ = database.GetDB().Delete(&model.AgentNode{}, "id = ?", c.Param("id")).Error
|
||
// Item 7 B3: the agent's probe data dies with its row (hub
|
||
// delete_node cascades the same set, metrics and traffic
|
||
// included) and the shared snapshot drops it at once.
|
||
if id, err := strconv.Atoi(c.Param("id")); err == nil {
|
||
probe.DeleteAgentData(int64(id))
|
||
service.InvalidateProbeSnapshot()
|
||
}
|
||
jsonObj(c, gin.H{"message": "deleted"}, nil)
|
||
}
|
||
|
||
// getEgress returns the panel-managed egress (outbounds + routing rules) for
|
||
// the agent identified by its token.
|
||
func (a *AgentController) getEgress(c *gin.Context) {
|
||
agent, ok := a.findAgent(c.Param("token"))
|
||
if !ok {
|
||
c.JSON(404, gin.H{"message": "agent not found"})
|
||
return
|
||
}
|
||
jsonObj(c, gin.H{
|
||
"id": agent.Id,
|
||
"token_prefix": agent.TokenPrefix,
|
||
"name": agent.Name,
|
||
"node_id": agent.NodeID,
|
||
"outbounds": json.RawMessage(orEmptyArray(agent.Outbounds)),
|
||
"routing_rules": json.RawMessage(orEmptyArray(agent.RoutingRules)),
|
||
}, nil)
|
||
}
|
||
|
||
// setEgress replaces the panel-managed egress for the agent. The panel is the
|
||
// only source of truth: agents never report local egress, they only apply
|
||
// what the generated config carries.
|
||
func (a *AgentController) setEgress(c *gin.Context) {
|
||
agent, ok := a.findAgent(c.Param("token"))
|
||
if !ok {
|
||
c.JSON(404, gin.H{"message": "agent not found"})
|
||
return
|
||
}
|
||
var req struct {
|
||
Outbounds json.RawMessage `json:"outbounds"`
|
||
RoutingRules json.RawMessage `json:"routing_rules"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
jsonMsg(c, "Invalid request", err)
|
||
return
|
||
}
|
||
outbounds, err := canonicalJSONArray(req.Outbounds)
|
||
if err != nil {
|
||
jsonMsg(c, "outbounds must be a JSON array", err)
|
||
return
|
||
}
|
||
rules, err := canonicalJSONArray(req.RoutingRules)
|
||
if err != nil {
|
||
jsonMsg(c, "routing_rules must be a JSON array", err)
|
||
return
|
||
}
|
||
if err := database.GetDB().Model(&model.AgentNode{}).Where("id = ?", agent.Id).
|
||
Updates(map[string]any{"outbounds": outbounds, "routing_rules": rules}).Error; err != nil {
|
||
jsonMsg(c, "Failed to store egress", err)
|
||
return
|
||
}
|
||
jsonObj(c, gin.H{"message": "saved"}, nil)
|
||
}
|
||
|
||
func orEmptyArray(s string) string {
|
||
if s == "" {
|
||
return "[]"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// canonicalJSONArray validates that raw is a JSON array and returns it in
|
||
// canonical form. Empty input yields an empty string (template defaults).
|
||
func canonicalJSONArray(raw json.RawMessage) (string, error) {
|
||
if len(raw) == 0 {
|
||
return "", nil
|
||
}
|
||
var arr []any
|
||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||
return "", err
|
||
}
|
||
out, err := json.Marshal(arr)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(out), nil
|
||
}
|