feat(probe): public contract API + theme hosting + meta editing (item 7 B3)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user