feat(probe): public contract API + theme hosting + meta editing (item 7 B3)

This commit is contained in:
Muse
2026-10-22 21:39:06 +00:00
parent 3e052c42b8
commit 540c05b0a5
28 changed files with 3279 additions and 13 deletions
+7
View File
@@ -767,6 +767,13 @@ func (a *AgentController) listTokens(c *gin.Context) {
func (a *AgentController) deleteToken(c *gin.Context) {
_ = database.GetDB().Delete(&model.AgentNode{}, "id = ?", c.Param("id")).Error
// Item 7 B3: the agent's probe data dies with its row (hub
// delete_node cascades the same set, metrics and traffic
// included) and the shared snapshot drops it at once.
if id, err := strconv.Atoi(c.Param("id")); err == nil {
probe.DeleteAgentData(int64(id))
service.InvalidateProbeSnapshot()
}
jsonObj(c, gin.H{"message": "deleted"}, nil)
}
+3
View File
@@ -195,6 +195,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
// never reach these.
agentAdmin := api.Group("/agent")
a.agentController.RegisterAdminRoutes(agentAdmin)
// Item 7 B3: probe metadata editing rides the same admin group
// (/panel/api/agent/probe-meta/:nodeId).
NewProbeMetaController(agentAdmin)
// Hosts API — per-inbound override endpoints for subscription links
hosts := api.Group("/hosts")
+261
View File
@@ -0,0 +1,261 @@
package controller
import (
"fmt"
"math"
"net"
"strconv"
"strings"
"time"
"unicode"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/gin-gonic/gin"
)
// Item 7 B3: probe metadata editing (minimal set). The fields are the
// operator-owned display/plan data of probe_meta (ITEM7-SCOPE §5.2),
// edited from the node page's agent edit dialog and saved through the
// panel-authenticated agent admin group — effective immediately (the
// snapshot cache is invalidated; no restart anywhere). Validation
// mirrors the hub's patch_error chain field for field.
type ProbeMetaController struct{}
func NewProbeMetaController(g *gin.RouterGroup) *ProbeMetaController {
pc := &ProbeMetaController{}
g.GET("/probe-meta/:nodeId", pc.getMeta)
g.PUT("/probe-meta/:nodeId", pc.putMeta)
return pc
}
// probeMetaForm is the wire shape of the edit form: the full meta row,
// replaced wholesale on save.
type probeMetaForm struct {
Price float64 `json:"price"`
Currency string `json:"currency"`
BillingCycle string `json:"billingCycle"`
ExpiresAt *string `json:"expiresAt"`
TrafficLimit int64 `json:"trafficLimit"`
TrafficMode string `json:"trafficMode"`
TrafficResetDay int64 `json:"trafficResetDay"`
Public bool `json:"public"`
Sort int64 `json:"sort"`
Group string `json:"group"`
PublicRemark string `json:"publicRemark"`
Notify bool `json:"notify"`
CountryPin string `json:"countryPin"`
IPv4Pin string `json:"ipv4Pin"`
IPv6Pin string `json:"ipv6Pin"`
}
func metaToForm(m probe.Meta) probeMetaForm {
return probeMetaForm{
Price: m.Price,
Currency: m.Currency,
BillingCycle: m.BillingCycle,
ExpiresAt: m.ExpiresAt,
TrafficLimit: m.TrafficLimit,
TrafficMode: m.TrafficMode,
TrafficResetDay: m.TrafficResetDay,
Public: m.Public,
Sort: m.Sort,
Group: m.GroupName,
PublicRemark: m.PublicRemark,
Notify: m.Notify,
CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin,
}
}
func (f probeMetaForm) toMeta(agentID int64) probe.Meta {
return probe.Meta{
AgentID: agentID,
Price: f.Price,
Currency: f.Currency,
BillingCycle: f.BillingCycle,
ExpiresAt: f.ExpiresAt,
TrafficLimit: f.TrafficLimit,
TrafficMode: f.TrafficMode,
TrafficResetDay: f.TrafficResetDay,
Public: f.Public,
Sort: f.Sort,
GroupName: f.Group,
PublicRemark: f.PublicRemark,
Notify: f.Notify,
CountryPin: f.CountryPin,
IPv4Pin: f.IPv4Pin,
IPv6Pin: f.IPv6Pin,
}
}
// agentForNode resolves the agent bound to a panel node row.
func agentForNode(c *gin.Context) (model.AgentNode, bool) {
nodeID, err := strconv.Atoi(c.Param("nodeId"))
if err != nil {
return model.AgentNode{}, false
}
var agent model.AgentNode
if err := database.GetDB().Where("node_id = ?", nodeID).First(&agent).Error; err != nil {
return model.AgentNode{}, false
}
return agent, true
}
func (pc *ProbeMetaController) getMeta(c *gin.Context) {
agent, ok := agentForNode(c)
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
meta, found, err := probe.MetaFor(int64(agent.Id))
if err != nil {
jsonMsg(c, "load probe meta failed", err)
return
}
if !found {
meta = probe.DefaultMeta(int64(agent.Id))
}
jsonObj(c, metaToForm(meta), nil)
}
func (pc *ProbeMetaController) putMeta(c *gin.Context) {
agent, ok := agentForNode(c)
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
var form probeMetaForm
if err := c.ShouldBindJSON(&form); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
if msg := validateProbeMetaForm(&form); msg != "" {
jsonMsg(c, msg, errRequest)
return
}
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
}
service.InvalidateProbeSnapshot()
jsonObj(c, gin.H{"message": "ok"}, nil)
}
// ---- hub patch_error ports ----
func hasControl(s string) bool {
for _, r := range s {
if unicode.IsControl(r) {
return true
}
}
return false
}
var namedCycles = map[string]int64{
"monthly": 1, "quarterly": 3, "semiannual": 6,
"yearly": 12, "biennial": 24, "triennial": 36,
}
func cycleMonths(cycle string) (int64, bool) {
if m, ok := namedCycles[cycle]; ok {
return m, true
}
if strings.HasSuffix(cycle, "m") {
if n, err := strconv.ParseInt(strings.TrimSuffix(cycle, "m"), 10, 64); err == nil && n >= 1 && n <= 1200 {
return n, true
}
}
return 0, false
}
func cycleName(months int64) string {
for name, m := range namedCycles {
if m == months {
return name
}
}
return fmt.Sprintf("%dm", months)
}
// validateProbeMetaForm normalizes the form in place (hub patch_error
// order: group, public remark, limits, billing, pins) and returns the
// first refusal's message, "" when the form may be stored.
func validateProbeMetaForm(f *probeMetaForm) string {
f.Group = strings.TrimSpace(f.Group)
if len([]rune(f.Group)) > 13 || hasControl(f.Group) {
return "分组名最多 13 个字,不能含控制字符"
}
f.PublicRemark = strings.TrimSpace(f.PublicRemark)
if len([]rune(f.PublicRemark)) > 100 || hasControl(f.PublicRemark) {
return "公开备注最多 100 个字,不能含控制字符"
}
if f.TrafficResetDay < 1 || f.TrafficResetDay > 31 {
return "流量重置日要在 1 到 31 之间"
}
if math.IsNaN(f.Price) || math.IsInf(f.Price, 0) || f.Price < 0 || f.TrafficLimit < 0 {
return "价格和流量上限不能是负数"
}
f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency))
if len(f.Currency) != 3 || !isUpperASCII(f.Currency) {
return "货币要填三个字母的代码,如 USD、CNY、HKD"
}
if f.BillingCycle != "once" {
months, ok := cycleMonths(f.BillingCycle)
if !ok {
return "付款周期要是整月,在 1 个月到 100 年之间"
}
f.BillingCycle = cycleName(months)
}
f.CountryPin = strings.ToUpper(strings.TrimSpace(f.CountryPin))
if f.CountryPin != "" && (len(f.CountryPin) != 2 || !isUpperASCII(f.CountryPin)) {
return "国家要填两个字母的代码,留空则自动识别"
}
for _, pin := range []struct {
val *string
v6 bool
}{
{&f.IPv4Pin, false},
{&f.IPv6Pin, true},
} {
typed := strings.TrimSpace(*pin.val)
if typed == "" {
*pin.val = ""
continue
}
ip := net.ParseIP(typed)
if ip == nil || (ip.To4() == nil) != pin.v6 {
if pin.v6 {
return "IPv6 要填一个 IPv6 地址,或者留空"
}
return "IPv4 要填一个 IPv4 地址,或者留空"
}
*pin.val = ip.String()
}
if f.ExpiresAt != nil {
v := strings.TrimSpace(*f.ExpiresAt)
if v == "" {
f.ExpiresAt = nil
} else if _, err := time.ParseInLocation("2006-01-02", v, time.Local); err != nil {
return "到期日期格式应为 YYYY-MM-DD"
} else {
f.ExpiresAt = &v
}
}
return ""
}
func isUpperASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < 'A' || s[i] > 'Z' {
return false
}
}
return true
}
+362
View File
@@ -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, "/"))
}
@@ -0,0 +1,592 @@
package controller
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
ws "github.com/gorilla/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"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"
)
// Item 7 B3 contract tests: the five public endpoints, audience
// discipline (anonymous callers never see addresses / hostnames /
// private remarks), theme hosting + fallback, theme config round
// trip, and the probe-meta edit endpoints. Field sets are asserted
// exactly against the hub's node_view / metrics definitions.
type probeTestRig struct {
engine *gin.Engine
dbDir string
store *probe.Store
cookies []*http.Cookie
}
func newProbeTestRig(t *testing.T) *probeTestRig {
t.Helper()
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
st := probe.Default()
if st == nil {
t.Fatal("probe store unavailable")
}
service.InvalidateProbeSnapshot()
engine := gin.New()
store := cookie.NewStore([]byte("probe-public-test-secret"))
engine.Use(sessions.Sessions("3x-ui", store))
engine.Use(func(c *gin.Context) { c.Set("base_path", "/panel/") })
a := &APIController{}
engine.GET("/test-login", func(c *gin.Context) {
var user model.User
if err := database.GetDB().First(&user).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
if err := session.SetLoginUser(c, &user); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
})
pc := NewPublicProbeController(engine)
agentAdmin := engine.Group("/panel/api/agent")
agentAdmin.Use(a.checkAPIAuth)
NewProbeMetaController(agentAdmin)
engine.NoRoute(func(c *gin.Context) {
if pc.HandleFallback(c) {
return
}
c.AbortWithStatus(http.StatusNotFound)
})
rig := &probeTestRig{engine: engine, dbDir: dbDir, store: st}
// Log in once; handlers that need the anonymous side just skip the
// cookies.
rec := rig.do(http.MethodGet, "/test-login", nil, false)
if rec.Code != http.StatusOK {
t.Fatalf("test login: %d", rec.Code)
}
rig.cookies = rec.Result().Cookies()
if len(rig.cookies) == 0 {
t.Fatal("test login set no session cookie")
}
return rig
}
func (r *probeTestRig) do(method, path string, body []byte, authed bool) *httptest.ResponseRecorder {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, rd)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if authed {
for _, ck := range r.cookies {
req.AddCookie(ck)
}
}
rec := httptest.NewRecorder()
r.engine.ServeHTTP(rec, req)
return rec
}
func (r *probeTestRig) setSetting(t *testing.T, key, value string) {
t.Helper()
db := database.GetDB()
var row model.Setting
err := db.Where("key = ?", key).First(&row).Error
if database.IsNotFound(err) {
if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
t.Fatalf("set setting %s: %v", key, err)
}
return
}
if err != nil {
t.Fatalf("read setting %s: %v", key, err)
}
row.Value = value
if err := db.Save(&row).Error; err != nil {
t.Fatalf("save setting %s: %v", key, err)
}
}
// seedAgent creates a bound node + agent pair with a live probe
// snapshot, a meta row and a traffic ledger line.
func (r *probeTestRig) seedAgent(t *testing.T, name string, public bool) (agentID int64, nodeID int) {
t.Helper()
db := database.GetDB()
node := &model.Node{Name: name, Remark: "private-remark-" + name}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
now := time.Now()
agent := &model.AgentNode{
TokenHash: "hash-" + name, TokenPrefix: "tp-", Name: "agent-" + name,
NodeID: &node.Id,
LastSeen: now.UnixMilli(),
AgentVersion: "A1.0.0",
IPv4: "203.0.113.9",
SeenIPv4: "198.51.100.4",
ProbeHostname: "secret-host-" + name,
ProbeOS: "Debian GNU/Linux 13", ProbeKernel: "6.12.1", ProbeArch: "amd64",
ProbeVirt: "kvm", ProbeCPUName: "Test CPU", ProbeCPUCores: 4,
ProbeIface: "eth0",
ProbeMemTotal: 4_000_000_000, ProbeMemUsed: 1_500_000_000,
ProbeSwapTotal: 1_000_000_000, ProbeSwapUsed: 5_000_000,
ProbeDiskTotal: 8_000_000_000, ProbeDiskUsed: 3_000_000_000,
ProbeNetRxTot: 111_000, ProbeNetTxTot: 222_000,
ProbeNetRx: 1000, ProbeNetTx: 2000,
ProbeTCP: 42, ProbeUDP: 7, ProbeProcs: 123,
ProbeCPU: 12.5, UptimeSecs: 3600,
ProbeAt: now.UnixMilli(),
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
id := int64(agent.Id)
r.store.DeleteAgentData(id) // the default store is process-wide; start clean
exp := now.AddDate(0, 1, 0).Format("2006-01-02")
meta := probe.DefaultMeta(id)
meta.Public = public
meta.Price = 10
meta.ExpiresAt = &exp
meta.GroupName = "grp"
meta.PublicRemark = "shown publicly"
if err := probe.SaveMeta(meta); err != nil {
t.Fatalf("seed meta: %v", err)
}
month := probe.PeriodStart(now, 1).Format("2006-01-02")
day := now.Format("2006-01-02")
if _, err := r.store.DB().Exec(`INSERT INTO probe_traffic
(agent_id, total_rx, total_tx, month_rx, month_tx, month_start, day_rx, day_tx, day_start)
VALUES (?, 111, 222, 33, 44, ?, 5, 6, ?)`, id, month, day); err != nil {
t.Fatalf("seed traffic: %v", err)
}
return id, node.Id
}
func keySet(t *testing.T, body []byte, path ...string) map[string]bool {
t.Helper()
var v any
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v", err)
}
cur := v
for _, p := range path {
m, ok := cur.(map[string]any)
if !ok {
t.Fatalf("path %v: not an object at %q", path, p)
}
cur = m[p]
}
m, ok := cur.(map[string]any)
if !ok {
t.Fatalf("path %v: not an object", path)
}
out := map[string]bool{}
for k := range m {
out[k] = true
}
return out
}
func assertKeys(t *testing.T, got map[string]bool, want []string) {
t.Helper()
w := map[string]bool{}
for _, k := range want {
w[k] = true
}
var extra, missing []string
for k := range got {
if !w[k] {
extra = append(extra, k)
}
}
for k := range w {
if !got[k] {
missing = append(missing, k)
}
}
sort.Strings(extra)
sort.Strings(missing)
if len(extra) > 0 || len(missing) > 0 {
t.Fatalf("key set mismatch: extra=%v missing=%v", extra, missing)
}
}
var anonNodeKeys = []string{
"id", "name", "country", "group", "public_remark", "sort", "public",
"online", "last_seen", "last_seen_ago", "metrics",
"os", "kernel", "arch", "virt", "cpu_name", "cpu_cores",
"mem_total", "swap_total", "disk_total", "agent_version",
"price", "currency", "billing_cycle", "expires_at", "expires_in",
"traffic_limit", "traffic_mode", "traffic_reset_day",
"total_rx", "total_tx", "month_rx", "month_tx", "month_used",
"month_start", "day_rx", "day_tx",
}
var metricKeys = []string{
"uptime", "cpu", "load", "mem_total", "mem_used", "swap_total",
"swap_used", "disk_total", "disk_used", "net_rx", "net_tx",
"tcp", "udp", "procs", "total_rx", "total_tx", "month_rx", "month_tx",
}
var historyKeys = []string{
"ts", "cpu", "mem_used", "disk_used", "net_rx", "net_tx",
"net_rx_max", "net_tx_max", "cpu_max", "minutes",
"swap_used", "tcp", "udp", "procs",
}
func TestProbePublicContract(t *testing.T) {
rig := newProbeTestRig(t)
pubID, _ := rig.seedAgent(t, "PubNode", true)
privID, privNodeID := rig.seedAgent(t, "PrivNode", false)
service.InvalidateProbeSnapshot()
// --- public page off (the shipped default) ---
rec := rig.do(http.MethodGet, "/api/me", nil, false)
if rec.Code != 200 {
t.Fatalf("me anon: %d", rec.Code)
}
assertKeys(t, keySet(t, rec.Body.Bytes()),
[]string{"authed", "github", "site_name", "public_page", "history_days", "site"})
var me map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &me)
if me["authed"] != false || me["public_page"] != false || me["site_name"] != "Monitor" ||
me["history_days"] != float64(7) || me["github"] != false || me["site"] != "" {
t.Fatalf("me defaults wrong: %v", me)
}
if rec := rig.do(http.MethodGet, "/api/nodes", nil, false); rec.Code != 401 {
t.Fatalf("nodes anon with page off: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/nodes/1/metrics", nil, false); rec.Code != 401 {
t.Fatalf("metrics anon with page off: %d", rec.Code)
}
// --- public page on: the anonymous audience ---
rig.setSetting(t, "public_page", "on")
service.InvalidateProbeSnapshot()
rec = rig.do(http.MethodGet, "/api/nodes", nil, false)
if rec.Code != 200 {
t.Fatalf("nodes anon: %d (%s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, leak := range []string{
"secret-host", "private-remark", `"hostname"`, `"remark"`,
`"ip":`, `"ipv4"`, `"ipv6"`, "203.0.113.9", "198.51.100.4", "eth0",
} {
if strings.Contains(body, leak) {
t.Fatalf("anonymous frame leaks %q: %s", leak, body)
}
}
var frame struct {
Nodes []map[string]json.RawMessage `json:"nodes"`
Admin bool `json:"admin"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
t.Fatalf("frame decode: %v", err)
}
if frame.Admin || len(frame.Nodes) != 1 {
t.Fatalf("anon frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
}
nodeBody, _ := json.Marshal(frame.Nodes[0])
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
var node map[string]any
_ = json.Unmarshal(nodeBody, &node)
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
t.Fatalf("anon node values wrong: %v", node)
}
metricsObj, _ := json.Marshal(node["metrics"])
assertKeys(t, keySet(t, metricsObj), metricKeys)
// Metrics history for the public node; the private one refuses.
base := time.Now().Unix()
base -= base % 60
for _, ts := range []int64{base - 120, base - 60} {
if _, err := rig.store.DB().Exec(`INSERT INTO probe_metric
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max)
VALUES (?, ?, 10, 100, 0, 0, 1000, 2000, 1500, 2500, 3, 2, 10, 12)`,
pubID, ts); err != nil {
t.Fatalf("seed metric: %v", err)
}
}
rec = rig.do(http.MethodGet, "/api/nodes/"+itoa(pubID)+"/metrics?hours=1", nil, false)
if rec.Code != 200 {
t.Fatalf("metrics anon: %d (%s)", rec.Code, rec.Body.String())
}
var hist struct {
Metrics []map[string]json.RawMessage `json:"metrics"`
Ping []any `json:"ping"`
Probes map[string]string `json:"probes"`
Loss map[string]float64 `json:"loss"`
Step int64 `json:"step"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
t.Fatalf("metrics decode: %v", err)
}
if hist.Step != 60 || len(hist.Metrics) != 2 || len(hist.Ping) != 0 ||
len(hist.Probes) != 0 || len(hist.Loss) != 0 {
t.Fatalf("metrics shape wrong: %+v", hist)
}
rowBody, _ := json.Marshal(hist.Metrics[0])
assertKeys(t, keySet(t, rowBody), historyKeys)
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, false); rec.Code != 401 {
t.Fatalf("metrics for private node anon: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/nodes/abc/metrics", nil, false); rec.Code != 400 {
t.Fatalf("metrics bad id: %d", rec.Code)
}
// --- the admin audience ---
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
if rec.Code != 200 {
t.Fatalf("nodes admin: %d", rec.Code)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Fatalf("admin frame cache header: %q", cc)
}
_ = json.Unmarshal(rec.Body.Bytes(), &frame)
if !frame.Admin || len(frame.Nodes) != 2 {
t.Fatalf("admin frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
}
adminBody := rec.Body.String()
for _, want := range []string{"secret-host-PubNode", "private-remark-PubNode", "203.0.113.9"} {
if !strings.Contains(adminBody, want) {
t.Fatalf("admin frame missing %q", want)
}
}
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, true); rec.Code != 200 {
t.Fatalf("metrics for private node admin: %d", rec.Code)
}
// --- theme hosting + API 404 discipline ---
themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist")
if err := os.MkdirAll(filepath.Join(themeDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
indexHTML := "<!doctype html><title>theme</title>"
if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(themeDir, "assets", "app-abc123.js"), []byte("console.log(1)"), 0o644); err != nil {
t.Fatal(err)
}
rig.setSetting(t, "theme", "testtheme")
rec = rig.do(http.MethodGet, "/", nil, false)
if rec.Code != 200 || rec.Body.String() != indexHTML {
t.Fatalf("theme root: %d %q", rec.Code, rec.Body.String())
}
etag := rec.Header().Get("ETag")
if etag == "" || !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") {
t.Fatalf("shell headers: etag=%q cc=%q", etag, rec.Header().Get("Cache-Control"))
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-None-Match", etag)
rec304 := httptest.NewRecorder()
rig.engine.ServeHTTP(rec304, req)
if rec304.Code != 304 {
t.Fatalf("etag revalidation: %d", rec304.Code)
}
if rec := rig.do(http.MethodGet, "/some/spa/route", nil, false); rec.Code != 200 || rec.Body.String() != indexHTML {
t.Fatalf("spa fallback: %d", rec.Code)
}
rec = rig.do(http.MethodGet, "/assets/app-abc123.js", nil, false)
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Fatalf("asset serving: %d cc=%q", rec.Code, rec.Header().Get("Cache-Control"))
}
if rec := rig.do(http.MethodGet, "/assets/missing.js", nil, false); rec.Code != 404 {
t.Fatalf("missing asset: %d", rec.Code)
}
rec = rig.do(http.MethodGet, "/api/nope", nil, false)
if rec.Code != 404 || !strings.Contains(rec.Body.String(), "没有这个接口:/api/nope") {
t.Fatalf("api 404: %d %q", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodGet, "/panel/unknown", nil, false); rec.Code != 404 {
t.Fatalf("panel path must stay with the panel: %d", rec.Code)
}
// --- theme config round trip ---
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Code != 200 || rec.Body.String() != "{}" {
t.Fatalf("config default: %d %q", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), false); rec.Code != 401 {
t.Fatalf("config PUT anon: %d", rec.Code)
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), true); rec.Code != 204 {
t.Fatalf("config PUT admin: %d (%s)", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Body.String() != `{"accent":"red"}` {
t.Fatalf("config round trip: %q", rec.Body.String())
}
if rec := rig.do(http.MethodPut, "/api/themes/notinstalled/config", []byte(`{}`), true); rec.Code != 400 {
t.Fatalf("config PUT uninstalled: %d", rec.Code)
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`[1,2]`), true); rec.Code != 400 {
t.Fatalf("config PUT non-object: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/themes/bad!short/config", nil, true); rec.Code != 404 {
t.Fatalf("config bad short: %d", rec.Code)
}
// --- probe-meta edit endpoints ---
metaPath := "/panel/api/agent/probe-meta/" + itoa(int64(privNodeID))
if rec := rig.do(http.MethodGet, metaPath, nil, false); rec.Code == 200 {
t.Fatal("probe-meta GET without login must not succeed")
}
rec = rig.do(http.MethodGet, metaPath, nil, true)
if rec.Code != 200 {
t.Fatalf("probe-meta GET: %d (%s)", rec.Code, rec.Body.String())
}
var env struct {
Success bool `json:"success"`
Obj struct {
Currency string `json:"currency"`
BillingCycle string `json:"billingCycle"`
Public bool `json:"public"`
} `json:"obj"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if !env.Success || env.Obj.Currency != "USD" || env.Obj.Public || env.Obj.BillingCycle != "monthly" {
t.Fatalf("probe-meta GET body: %s", rec.Body.String())
}
bad := []byte(`{"price":1,"currency":"USD","billingCycle":"monthly","trafficLimit":0,` +
`"trafficMode":"sum","trafficResetDay":32,"public":true}`)
rec = rig.do(http.MethodPut, metaPath, bad, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env.Success {
t.Fatalf("reset day 32 must be refused: %s", rec.Body.String())
}
good := []byte(`{"price":3.5,"currency":"usd","billingCycle":"12m","trafficLimit":2000000000,` +
`"trafficMode":"max","trafficResetDay":15,"public":false,"ipv4Pin":" 203.0.113.9 "}`)
rec = rig.do(http.MethodPut, metaPath, good, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if !env.Success {
t.Fatalf("probe-meta PUT: %s", rec.Body.String())
}
rec = rig.do(http.MethodGet, metaPath, nil, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env.Obj.Currency != "USD" || env.Obj.BillingCycle != "yearly" || env.Obj.Public {
t.Fatalf("probe-meta normalization not stored: %s", rec.Body.String())
}
saved, ok, err := probe.MetaFor(privID)
if err != nil || !ok || saved.IPv4Pin != "203.0.113.9" || saved.TrafficLimit != 2_000_000_000 {
t.Fatalf("stored meta: ok=%v err=%v %+v", ok, err, saved)
}
if rec := rig.do(http.MethodGet, "/panel/api/agent/probe-meta/999999", nil, true); rec.Code != 404 {
t.Fatalf("probe-meta unbound node: %d", rec.Code)
}
}
func TestProbeWebSocket(t *testing.T) {
rig := newProbeTestRig(t)
_, _ = rig.seedAgent(t, "WsNode", true)
rig.setSetting(t, "public_page", "on")
service.InvalidateProbeSnapshot()
srv := httptest.NewServer(rig.engine)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
readFrame := func(conn *ws.Conn) (int, map[string]any) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
mt, raw, err := conn.ReadMessage()
if err != nil {
t.Fatalf("ws read: %v", err)
}
if mt == ws.BinaryMessage {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("gzip frame: %v", err)
}
raw, err = io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip frame: %v", err)
}
}
var frame map[string]any
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%q)", err, raw)
}
return mt, frame
}
// Anonymous text frame.
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial anon: %v", err)
}
mt, frame := readFrame(conn)
if mt != ws.TextMessage || frame["admin"] != false {
t.Fatalf("anon frame: type=%d admin=%v", mt, frame["admin"])
}
if nodes, ok := frame["nodes"].([]any); !ok || len(nodes) != 1 {
t.Fatalf("anon frame nodes: %v", frame["nodes"])
}
conn.Close()
// Anonymous gzip frame: binary, gunzips to the same shape.
conn, _, err = ws.DefaultDialer.Dial(wsURL+"?gzip=1", nil)
if err != nil {
t.Fatalf("dial gzip: %v", err)
}
mt, frame = readFrame(conn)
if mt != ws.BinaryMessage || frame["admin"] != false {
t.Fatalf("gzip frame: type=%d admin=%v", mt, frame["admin"])
}
conn.Close()
// Admin frame over the session cookie.
hdr := http.Header{}
for _, ck := range rig.cookies {
hdr.Add("Cookie", ck.String())
}
conn, _, err = ws.DefaultDialer.Dial(wsURL, hdr)
if err != nil {
t.Fatalf("dial admin: %v", err)
}
_, frame = readFrame(conn)
if frame["admin"] != true {
t.Fatalf("admin frame: %v", frame["admin"])
}
conn.Close()
}
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}