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, "/")) }