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
396 lines
12 KiB
Go
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, "/"))
|
|
}
|