Files
3x-ui-agent/internal/web/controller/probe_public.go
T
Muse d4d259fa58
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
probe ws: frame cadence follows probe_fast_interval (V2.0.2)
2026-10-23 18:15:05 +08:00

396 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 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() {
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; 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
}
for {
timer := time.NewTimer(time.Duration(service.ProbeFastIntervalSec()) * time.Second)
select {
case <-done:
timer.Stop()
return
case <-timer.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")
// Hub save_theme_config gates on selectable, not on a disk copy:
// the built-in theme's config is savable before any install.
if !service.ValidThemeShort(short) || !service.ProbeThemeSelectable(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
}
// V2.0.1 ③: with the public page switched off, the theme shell
// is not public content — an anonymous visitor gets a plain 404
// (the pre-item-7 behaviour for the root path), never the shell
// whose "public page off" redirect chain dead-ends on /admin.
// A logged-in operator still reaches the theme (preview, theme
// config edits), exactly like the data APIs' own gate.
if !service.ProbePublicPageEnabled() && !probeAuthed(c) {
answerText(c, http.StatusNotFound, "404 page not found")
return true
}
return service.ServeProbeThemePath(c, strings.TrimPrefix(path, "/"))
}