feat(probe): public contract API + theme hosting + meta editing (item 7 B3)
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
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 []any `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"
|
||||
_ = wantPing // ping rows are written by B4; until then the series is empty by construction
|
||||
|
||||
span := probe.ComputeSpan(hours, points, time.Now().Unix())
|
||||
resp := metricsResponse{
|
||||
Metrics: []probe.MetricRow{},
|
||||
Ping: []any{},
|
||||
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
|
||||
}
|
||||
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, "/"))
|
||||
}
|
||||
Reference in New Issue
Block a user