Files
3x-ui-agent/internal/web/controller/probe_public.go
T
Muse e03ceaa41f B4: probe task store, panel API, heartbeat ingestion, agent prober
- 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.
2026-10-22 21:57:23 +00:00

381 lines
12 KiB
Go

package controller
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"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/web/session"
"github.com/gin-gonic/gin"
ws "github.com/gorilla/websocket"
)
// Item 7 B3: the public status surface — the monitor hub's contract
// API, hosted by the panel process itself at the engine ROOT (outside
// the panel's secret basePath, or themes' root-relative /api calls
// could never reach it; ITEM7-SCOPE §5.5). Field shapes follow hub
// src/api.rs exactly: the five endpoints' payloads are the bare
// contract JSON, NOT the panel's Msg envelope.
//
// Audience discipline mirrors the hub: a request is "authed" (admin
// rendering) when it carries a live panel session; anonymous callers
// see only public=1 nodes and only while the public page is on, and
// no rendering ever carries ip/hostname/remark/token material.
// historySlots bounds concurrent history builds (hub HISTORY_GATE=4):
// refused, not queued, once full.
var historySlots = make(chan struct{}, 4)
// publicStreams counts open anonymous /api/ws streams (hub
// PUBLIC_STREAMS=1000); admin streams are not counted.
var publicStreams atomic.Int64
const maxThemeConfigBytes = 64 * 1024
type PublicProbeController struct {
upgrader ws.Upgrader
}
// NewPublicProbeController registers the contract routes on the
// engine root and returns the controller (web.go also calls its
// HandleFallback from the engine's NoRoute).
func NewPublicProbeController(engine *gin.Engine) *PublicProbeController {
pc := &PublicProbeController{
upgrader: ws.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
// Session trust for the stream is decided per tick from
// the panel session, not from Origin; the hub accepts
// any origin for the anonymous audience by design.
CheckOrigin: func(r *http.Request) bool { return true },
},
}
engine.GET("/api/me", pc.me)
engine.GET("/api/nodes", pc.nodes)
engine.GET("/api/nodes/:id/metrics", pc.nodeMetrics)
engine.GET("/api/ws", pc.liveWS)
engine.GET("/api/themes/:short/config", pc.themeConfig)
engine.PUT("/api/themes/:short/config", pc.saveThemeConfig)
return pc
}
func probeAuthed(c *gin.Context) bool {
return session.IsLogin(c)
}
// fromOtherOrigin mirrors hub from_other_origin: a browser request
// whose Sec-Fetch-Site is present and not "same-origin" did not come
// from this panel's own pages (a sibling subdomain is same-site but
// not same-origin, and a WebSocket is not held back by CORS).
func fromOtherOrigin(c *gin.Context) bool {
site := c.GetHeader("Sec-Fetch-Site")
return site != "" && site != "same-origin"
}
func answerText(c *gin.Context, status int, text string) {
c.String(status, "%s", text)
}
func (pc *PublicProbeController) me(c *gin.Context) {
c.JSON(http.StatusOK, struct {
Authed bool `json:"authed"`
GitHub bool `json:"github"`
SiteName string `json:"site_name"`
PublicPage bool `json:"public_page"`
HistoryDays int64 `json:"history_days"`
Site string `json:"site"`
}{
Authed: probeAuthed(c),
GitHub: service.ProbeGitHubConfigured(),
SiteName: service.ProbeSiteName(),
PublicPage: service.ProbePublicPageEnabled(),
HistoryDays: probe.RetentionDays(),
Site: service.ProbeSiteURL(),
})
}
func (pc *PublicProbeController) nodes(c *gin.Context) {
full := probeAuthed(c)
if !full && !service.ProbePublicPageEnabled() {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
if full {
// The admin frame carries every node's addresses and private
// notes; keep it out of every cache (hub discipline).
c.Header("Cache-Control", "no-store, no-transform")
}
// The engine's gzip middleware compresses for clients that accept
// it; the body is the shared snapshot frame either way.
c.Data(http.StatusOK, "application/json", service.ProbeSnapshotJSON(full))
}
type metricsResponse struct {
Metrics []probe.MetricRow `json:"metrics"`
Ping []probe.PingRow `json:"ping"`
Probes map[string]string `json:"probes"`
Loss map[string]float64 `json:"loss"`
Step int64 `json:"step"`
}
func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
full := probeAuthed(c)
if !service.ProbeNodeReadable(full, id) {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
select {
case historySlots <- struct{}{}:
defer func() { <-historySlots }()
default:
answerText(c, http.StatusServiceUnavailable, "查询历史的请求太多,稍后再试")
return
}
hours := int64(6)
if raw := c.Query("hours"); raw != "" {
h, perr := strconv.ParseInt(raw, 10, 64)
if perr != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
hours = h
}
if max := probe.RetentionDays() * 24; hours < 1 {
hours = 1
} else if hours > max {
hours = max
}
var points *int64
if raw := c.Query("points"); raw != "" {
p, perr := strconv.ParseInt(raw, 10, 64)
if perr != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
points = &p
}
series := c.Query("series")
wantMetrics := series == "" || series == "metrics"
wantPing := series == "" || series == "ping"
span := probe.ComputeSpan(hours, points, time.Now().Unix())
resp := metricsResponse{
Metrics: []probe.MetricRow{},
Ping: []probe.PingRow{},
Probes: map[string]string{},
Loss: map[string]float64{},
Step: span.Step,
}
if wantMetrics {
rows, err := probe.MetricHistory(id, span)
if err != nil {
logger.Warningf("probe public: metrics %d: %v", id, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
resp.Metrics = rows
}
if wantPing {
// B4: the real latency series over the same window/bucket as
// the metrics above (hub metrics handler series=ping).
pingRows, pingLoss, err := probe.PingHistory(id, span)
if err != nil {
logger.Warningf("probe public: ping %d: %v", id, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
names, err := probe.PingTaskNames(id)
if err != nil {
logger.Warningf("probe public: ping names %d: %v", id, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
resp.Ping = pingRows
resp.Probes = names
resp.Loss = pingLoss
}
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.
func (pc *PublicProbeController) liveWS(c *gin.Context) {
authed := probeAuthed(c) && !fromOtherOrigin(c)
if !authed && !service.ProbePublicPageEnabled() {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
counted := !authed
if counted {
if publicStreams.Add(1) > 1000 {
publicStreams.Add(-1)
answerText(c, http.StatusServiceUnavailable, "同时在线的访客太多,请稍后再试")
return
}
defer publicStreams.Add(-1)
}
_, gzipWanted := c.GetQuery("gzip")
conn, err := pc.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// The browser sends nothing but the close handshake; cap frames
// at the hub's 64 KiB anyway so a rogue peer cannot make the read
// pump buffer without bound.
conn.SetReadLimit(64 * 1024)
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
send := func() bool {
// Audience re-check (hub stream_audience): the admin frame is
// sent only while the session that opened it is still live;
// the public frame only while the page stays open. The
// stream closes rather than downgrading.
if authed {
if !probeAuthed(c) {
return false
}
} else if !service.ProbePublicPageEnabled() {
return false
}
var frameType int
var frame []byte
if gzipWanted && !authed {
frameType = ws.BinaryMessage
frame = service.ProbeSnapshotGzip()
} else if authed {
frameType = ws.TextMessage
frame = service.ProbeSnapshotJSON(true)
} else {
frameType = ws.TextMessage
frame = service.ProbeSnapshotJSON(false)
}
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return conn.WriteMessage(frameType, frame) == nil
}
// The first frame goes out immediately (hub's interval ticks at
// once), then one per 2 s tick.
if !send() {
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if !send() {
return
}
}
}
}
func (pc *PublicProbeController) themeConfig(c *gin.Context) {
if !probeAuthed(c) && !service.ProbePublicPageEnabled() {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
short := c.Param("short")
if !service.ValidThemeShort(short) {
answerText(c, http.StatusNotFound, "请求的内容不存在")
return
}
raw, err := service.ProbeThemeConfigValue(short)
if err != nil {
logger.Warningf("probe public: theme config %s: %v", short, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
c.Data(http.StatusOK, "application/json", []byte(raw))
}
func (pc *PublicProbeController) saveThemeConfig(c *gin.Context) {
if !probeAuthed(c) {
answerText(c, http.StatusUnauthorized, "登录已失效,请重新登录")
return
}
if fromOtherOrigin(c) {
answerText(c, http.StatusForbidden, "请求来自其他网站,已拒绝")
return
}
short := c.Param("short")
if !service.ValidThemeShort(short) || !service.ProbeThemeInstalled(short) {
answerText(c, http.StatusBadRequest, "主题没有安装")
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxThemeConfigBytes+1))
if err != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
if len(body) > maxThemeConfigBytes {
answerText(c, http.StatusRequestEntityTooLarge, "提交的内容过大")
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil || obj == nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
if err := service.SaveProbeThemeConfigRaw(short, string(body)); err != nil {
logger.Warningf("probe public: save theme config %s: %v", short, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
c.Status(http.StatusNoContent)
}
// HandleFallback serves the public theme from the engine's NoRoute:
// unknown /api/* paths answer the hub's API 404, everything outside
// the panel basePath falls to the current theme's dist (SPA fallback
// inside). Returns false when the request is the panel's own (its
// SPA fallback decides) or when no theme is installed to answer.
func (pc *PublicProbeController) HandleFallback(c *gin.Context) bool {
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
return false
}
path := c.Request.URL.Path
if path == "/api" || strings.HasPrefix(path, "/api/") {
answerText(c, http.StatusNotFound, "没有这个接口:"+path)
return true
}
basePath, _ := c.Get("base_path")
if bp, ok := basePath.(string); ok && bp != "" && bp != "/" {
if strings.HasPrefix(path, bp) {
return false
}
} else if strings.HasPrefix(path, "/panel") || strings.HasPrefix(path, "/assets") {
return false
}
return service.ServeProbeThemePath(c, strings.TrimPrefix(path, "/"))
}