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)
}
+152
View File
@@ -0,0 +1,152 @@
package probe
import (
"database/sql"
"strings"
)
// Meta is one agent's probe display/operations metadata (hub node row's
// operator-owned half, ITEM7-SCOPE §5.2): what the public page shows
// beyond the machine's own reports — price/expiry/plan figures, the
// public switch and ordering, grouping and the public remark, the
// notify opt-in and the hand-set address/country pins. Rows are
// ensured with hub defaults on the agent's first report; the editing
// surface (B3) reads and writes them here.
type Meta struct {
AgentID int64
Price float64
Currency string
BillingCycle string
ExpiresAt *string // "2006-01-02" or nil
TrafficLimit int64
TrafficMode string
TrafficResetDay int64
Public bool
Sort int64
GroupName string
PublicRemark string
Notify bool
CountryPin string
IPv4Pin string
IPv6Pin string
}
// DefaultMeta returns the metadata of an agent that has no row yet —
// the same values the row would carry once ensured (the probe_meta
// DDL defaults).
func DefaultMeta(agentID int64) Meta {
return Meta{
AgentID: agentID,
Currency: "USD",
BillingCycle: "monthly",
TrafficMode: "sum",
TrafficResetDay: 1,
Public: true,
}
}
const metaColumns = `agent_id, price, currency, billing_cycle, expires_at,
traffic_limit, traffic_mode, traffic_reset_day, public, sort,
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin`
func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
var m Meta
var expires sql.NullString
var public, notify int64
if err := sc.Scan(&m.AgentID, &m.Price, &m.Currency, &m.BillingCycle, &expires,
&m.TrafficLimit, &m.TrafficMode, &m.TrafficResetDay, &public, &m.Sort,
&m.GroupName, &m.PublicRemark, &notify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil {
return Meta{}, err
}
if expires.Valid && strings.TrimSpace(expires.String) != "" {
v := expires.String
m.ExpiresAt = &v
}
m.Public = public != 0
m.Notify = notify != 0
return m, nil
}
// Meta reads one agent's metadata row. ok is false when the agent has
// no row yet (it never reported); callers then use DefaultMeta.
func (s *Store) Meta(agentID int64) (Meta, bool, error) {
row := s.db.QueryRow(`SELECT `+metaColumns+` FROM probe_meta WHERE agent_id = ?`, agentID)
m, err := scanMeta(row)
if err == sql.ErrNoRows {
return Meta{}, false, nil
}
if err != nil {
return Meta{}, false, err
}
return m, true, nil
}
// AllMeta reads every metadata row, keyed by agent id.
func (s *Store) AllMeta() (map[int64]Meta, error) {
rows, err := s.db.Query(`SELECT ` + metaColumns + ` FROM probe_meta`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]Meta)
for rows.Next() {
m, err := scanMeta(rows)
if err != nil {
return nil, err
}
out[m.AgentID] = m
}
return out, rows.Err()
}
// UpsertMeta writes one agent's metadata row (insert or full replace).
func (s *Store) UpsertMeta(m Meta) error {
var expires any
if m.ExpiresAt != nil && strings.TrimSpace(*m.ExpiresAt) != "" {
expires = *m.ExpiresAt
}
_, err := s.db.Exec(
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(agent_id) DO UPDATE SET
price=excluded.price, currency=excluded.currency,
billing_cycle=excluded.billing_cycle, expires_at=excluded.expires_at,
traffic_limit=excluded.traffic_limit, traffic_mode=excluded.traffic_mode,
traffic_reset_day=excluded.traffic_reset_day, public=excluded.public,
sort=excluded.sort, group_name=excluded.group_name,
public_remark=excluded.public_remark, notify=excluded.notify,
country_pin=excluded.country_pin, ipv4_pin=excluded.ipv4_pin,
ipv6_pin=excluded.ipv6_pin`,
m.AgentID, m.Price, m.Currency, m.BillingCycle, expires,
m.TrafficLimit, m.TrafficMode, m.TrafficResetDay, boolToInt(m.Public), m.Sort,
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin)
return err
}
func boolToInt(b bool) int64 {
if b {
return 1
}
return 0
}
// DeleteAgentData removes every probe row of an agent that no longer
// exists: metadata, the traffic ledger and both history tiers. The
// hub keeps history inside its own node table; here probe.db is a
// separate file, so the panel-side agent deletion calls this to keep
// the two stores in step (B2 report §6 cleanup point).
func (s *Store) DeleteAgentData(agentID int64) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, table := range []string{
"probe_meta", "probe_traffic", "probe_metric",
"probe_metric_hour", "probe_ping_record", "probe_ping_hour",
} {
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE agent_id = ?`, agentID); err != nil {
return err
}
}
return tx.Commit()
}
+53
View File
@@ -83,3 +83,56 @@ func TrafficSnapshot() (map[int64]Traffic, error) {
}
return st.AllTraffic()
}
// MetaSnapshot is the B3 read port for display metadata: every agent's
// probe_meta row, keyed by agent id. Agents without a row are absent;
// callers substitute DefaultMeta.
func MetaSnapshot() (map[int64]Meta, error) {
st := Default()
if st == nil {
return map[int64]Meta{}, nil
}
return st.AllMeta()
}
// MetaFor reads one agent's metadata row from the default store.
// ok is false when the agent never reported (no row ensured yet).
func MetaFor(agentID int64) (Meta, bool, error) {
st := Default()
if st == nil {
return Meta{}, false, nil
}
return st.Meta(agentID)
}
// SaveMeta persists one agent's metadata row (B3 edit surface).
func SaveMeta(m Meta) error {
st := Default()
if st == nil {
return nil
}
return st.UpsertMeta(m)
}
// MetricHistory is the B3 read port for one agent's history window.
// With no store the window is simply empty.
func MetricHistory(agentID int64, sp Span) ([]MetricRow, error) {
st := Default()
if st == nil {
return []MetricRow{}, nil
}
return st.MetricHistory(agentID, sp)
}
// DeleteAgentData drops a removed agent's probe rows (B3 cleanup point
// called from agent deletion). Best-effort: failures are logged and
// swallowed, as a stale probe row must never block a panel operation.
func DeleteAgentData(agentID int64) {
st := Default()
if st == nil {
return
}
if err := st.DeleteAgentData(agentID); err != nil {
logger.Warningf("probe: delete agent %d data: %v", agentID, err)
}
}
+173
View File
@@ -0,0 +1,173 @@
package probe
import (
"database/sql"
"strconv"
)
// Span is one chart window as the read side answers it (hub db::Span):
// the window start in unix seconds, the seconds each returned point
// covers, and whether the window reaches past the minute tier and is
// read from the hourly one.
type Span struct {
Since int64
Step int64
Hourly bool
}
// ComputeSpan reproduces hub api::span(): the point budget defaults to
// 1440 (a day of minutes) and the caller's points may only lower it,
// clamped to [60, 1440]; windows longer than DetailDays read hourly
// rows, so the step is a whole number of minutes — or of hours past the
// detail window, so no hourly row straddles two points; the window
// start is aligned down to a step boundary.
func ComputeSpan(hours int64, points *int64, now int64) Span {
const samples = 1440
budget := int64(samples)
if points != nil {
budget = *points
}
if budget < 60 {
budget = 60
}
if budget > samples {
budget = samples
}
hourly := hours > DetailDays*24
unit := int64(60)
if hourly {
unit = 3600
}
// Rounded up, or the budget would not be one.
q := (hours*3600/unit + budget - 1) / budget
if q < 1 {
q = 1
}
step := unit * q
return Span{
Since: euclidDiv(now-hours*3600, step) * step,
Step: step,
Hourly: hourly,
}
}
func euclidDiv(a, b int64) int64 {
q := a / b
if a%b != 0 && (a < 0) != (b < 0) {
q--
}
return q
}
// MetricRow is one thinned history point, in the hub's key order and
// with the hub's value semantics: means for the gauge columns (byte
// columns truncated, count columns rounded — the asymmetry is the
// hub's, kept), maxima for the peak columns, and `minutes` counting
// the minute rows folded into the point.
type MetricRow struct {
Ts int64 `json:"ts"`
Cpu float64 `json:"cpu"`
MemUsed int64 `json:"mem_used"`
DiskUsed int64 `json:"disk_used"`
NetRx int64 `json:"net_rx"`
NetTx int64 `json:"net_tx"`
NetRxMax int64 `json:"net_rx_max"`
NetTxMax int64 `json:"net_tx_max"`
CpuMax float64 `json:"cpu_max"`
Minutes int64 `json:"minutes"`
SwapUsed int64 `json:"swap_used"`
Tcp int64 `json:"tcp"`
Udp int64 `json:"udp"`
Procs int64 `json:"procs"`
}
// RolledWatermark reads the roll-up watermark (unix seconds of the
// first hour not yet folded). ok is false when no pass has run yet.
func (s *Store) RolledWatermark() (int64, bool, error) {
var raw string
err := s.db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&raw)
if err == sql.ErrNoRows {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
n, perr := strconv.ParseInt(raw, 10, 64)
if perr != nil {
return 0, false, nil
}
return n, true, nil
}
const metricColumns = `(MIN(ts)/?3)*?3, AVG(cpu), CAST(AVG(mem_used) AS INTEGER),
CAST(AVG(disk_used) AS INTEGER),
CAST(AVG(net_rx) AS INTEGER), CAST(AVG(net_tx) AS INTEGER),
MAX(MAX(net_rx, net_rx_max)), MAX(MAX(net_tx, net_tx_max)),
MAX(MAX(cpu, cpu_max)), COUNT(*), CAST(AVG(swap_used) AS INTEGER),
CAST(ROUND(AVG(tcp)) AS INTEGER), CAST(ROUND(AVG(udp)) AS INTEGER),
CAST(ROUND(AVG(procs)) AS INTEGER)`
// MetricHistory answers one agent's history window (hub Db::metrics).
// Minute windows group minute rows directly. Hourly windows merge the
// folded hourly tier (up to the watermark) with the not-yet-folded
// minute rows and the newest week of minute rows, weighted by each
// row's minute count. Both statements run in one read snapshot so a
// roll-up committing in between cannot double-count an hour.
func (s *Store) MetricHistory(agentID int64, sp Span) ([]MetricRow, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var rows *sql.Rows
if !sp.Hourly {
rows, err = tx.Query(
`SELECT `+metricColumns+`
FROM probe_metric WHERE agent_id=?1 AND ts>=?2 GROUP BY ts/?3 ORDER BY ts/?3`,
agentID, sp.Since, sp.Step)
} else {
rolled := int64(-1) << 62 // hub: unwrap_or(i64::MIN)
var raw sql.NullString
if qerr := tx.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&raw); qerr != nil && qerr != sql.ErrNoRows {
return nil, qerr
} else if raw.Valid {
if n, perr := strconv.ParseInt(raw.String, 10, 64); perr == nil {
rolled = n
}
}
rows, err = tx.Query(
`SELECT (MIN(ts)/?3)*?3, SUM(cpu*w)/SUM(w), SUM(mem_used*w)/SUM(w), SUM(disk_used*w)/SUM(w),
SUM(net_rx*w)/SUM(w), SUM(net_tx*w)/SUM(w), MAX(rx_max), MAX(tx_max), MAX(cpu_top), SUM(w),
SUM(swap_used*w)/SUM(w), CAST(ROUND(SUM(tcp*w)*1.0/SUM(w)) AS INTEGER),
CAST(ROUND(SUM(udp*w)*1.0/SUM(w)) AS INTEGER),
CAST(ROUND(SUM(procs*w)*1.0/SUM(w)) AS INTEGER)
FROM (SELECT ts, minutes AS w, cpu, mem_used, disk_used, net_rx, net_tx,
net_rx_max AS rx_max, net_tx_max AS tx_max, cpu_max AS cpu_top,
swap_used, tcp, udp, procs
FROM probe_metric_hour WHERE agent_id=?1 AND ts>=?2 AND ts<?4
UNION ALL
SELECT ts, 1, cpu, mem_used, disk_used, net_rx, net_tx,
MAX(net_rx, net_rx_max), MAX(net_tx, net_tx_max), MAX(cpu, cpu_max),
swap_used, tcp, udp, procs
FROM probe_metric WHERE agent_id=?1
AND ts>=MAX(?2, ?4, (SELECT MAX(ts) FROM probe_metric WHERE agent_id=?1) - ?5))
GROUP BY ts/?3 ORDER BY ts/?3`,
agentID, sp.Since, sp.Step, rolled, DetailDays*86400)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := []MetricRow{}
for rows.Next() {
var r MetricRow
if err := rows.Scan(&r.Ts, &r.Cpu, &r.MemUsed, &r.DiskUsed, &r.NetRx, &r.NetTx,
&r.NetRxMax, &r.NetTxMax, &r.CpuMax, &r.Minutes, &r.SwapUsed, &r.Tcp, &r.Udp, &r.Procs); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
+227
View File
@@ -0,0 +1,227 @@
package probe
import (
"testing"
"time"
)
// Span math (hub api::span): budget defaults to 1440 points, the
// caller's points may only lower it and is clamped to [60, 1440];
// windows past the 7-day detail window switch to whole-hour steps;
// since aligns down to the step.
func TestComputeSpan(t *testing.T) {
now := int64(1_800_000_000) // arbitrary fixed instant
i64 := func(v int64) *int64 { return &v }
cases := []struct {
name string
hours int64
points *int64
wantStep int64
wantHourly bool
}{
{"default 6h", 6, nil, 60, false},
{"points lower the budget", 6, i64(60), 360, false},
{"points above cap ignored", 6, i64(99999), 60, false},
{"points below floor clamped", 1, i64(1), 60, false},
{"detail edge stays minute", DetailDays * 24, nil, 420, false},
{"past detail reads hourly", DetailDays*24 + 1, nil, 3600, true},
{"30 days hourly", 720, nil, 3600, true},
{"hourly step grows past budget", 24 * 365, nil, 3600 * 7, true},
}
for _, tc := range cases {
sp := ComputeSpan(tc.hours, tc.points, now)
if sp.Step != tc.wantStep || sp.Hourly != tc.wantHourly {
t.Errorf("%s: got step=%d hourly=%v, want step=%d hourly=%v",
tc.name, sp.Step, sp.Hourly, tc.wantStep, tc.wantHourly)
}
if sp.Since%sp.Step != 0 {
t.Errorf("%s: since %d not aligned to step %d", tc.name, sp.Since, sp.Step)
}
if want := now - tc.hours*3600; sp.Since > want || sp.Since <= want-sp.Step {
t.Errorf("%s: since %d not the aligned window start of %d", tc.name, sp.Since, want)
}
}
}
func insertMinute(t *testing.T, st *Store, agent, ts int64, cpu float64, memUsed, netRx, netRxMax int64, cpuMax float64, tcp int64) {
t.Helper()
_, err := st.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 (?, ?, ?, ?, 0, 0, ?, 0, ?, 0, ?, 0, 0, ?)`,
agent, ts, cpu, memUsed, netRx, netRxMax, tcp, cpuMax)
if err != nil {
t.Fatalf("insert minute: %v", err)
}
}
// Minute windows fold rows per bucket with the hub's asymmetric
// aggregates: byte columns truncated means, count columns rounded,
// peaks take the max of value and stored peak.
func TestMetricHistoryMinuteFold(t *testing.T) {
st := newTestStore(t)
now := time.Now().Unix()
base := now - now%60 - 120 // a minute boundary two minutes back
insertMinute(t, st, 7, base, 10, 100, 1000, 1500, 12, 3)
insertMinute(t, st, 7, base+60, 20, 201, 2000, 500, 25, 5)
// 1:1 buckets (step 60).
sp := ComputeSpan(1, nil, now)
rows, err := st.MetricHistory(7, sp)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(rows) != 2 {
t.Fatalf("want 2 points, got %d (%+v)", len(rows), rows)
}
if rows[0].Ts != base || rows[0].Cpu != 10 || rows[0].MemUsed != 100 ||
rows[0].NetRxMax != 1500 || rows[0].CpuMax != 12 || rows[0].Minutes != 1 || rows[0].Tcp != 3 {
t.Fatalf("point 0 wrong: %+v", rows[0])
}
if rows[1].Ts != base+60 || rows[1].NetRxMax != 2000 { // max(2000, 500)
t.Fatalf("point 1 wrong: %+v", rows[1])
}
// Both minutes in one 120 s bucket: cpu mean 15, mem truncated
// mean (100+201)/2 = 150, peaks survive, minutes counts rows.
sp2 := Span{Since: base, Step: 120, Hourly: false}
rows, err = st.MetricHistory(7, sp2)
if err != nil {
t.Fatalf("history 120s: %v", err)
}
if len(rows) != 1 {
t.Fatalf("want 1 folded point, got %d", len(rows))
}
r := rows[0]
if r.Ts != base || r.Cpu != 15 || r.MemUsed != 150 || r.Minutes != 2 ||
r.NetRxMax != 2000 || r.CpuMax != 25 || r.Tcp != 4 { // ROUND(avg(3,5)) = 4
t.Fatalf("folded point wrong: %+v", r)
}
// Another agent sees nothing.
rows, err = st.MetricHistory(8, sp)
if err != nil || len(rows) != 0 {
t.Fatalf("agent isolation: rows=%v err=%v", rows, err)
}
}
// Hourly windows merge the folded hour tier (weighted by each row's
// minute count) with the minute rows not yet folded.
func TestMetricHistoryHourlyFold(t *testing.T) {
st := newTestStore(t)
now := time.Now().Unix()
hour := now - now%3600
h1 := hour - 100*3600 // folded tier
h2 := h1 + 3600
ins := func(ts int64, cpu float64, mem int64, minutes int64) {
_, err := st.db.Exec(`INSERT INTO probe_metric_hour
(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, minutes)
VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?)`,
9, ts, cpu, mem, cpu, minutes)
if err != nil {
t.Fatalf("insert hour: %v", err)
}
}
ins(h1, 30, 300, 60)
ins(h2, 60, 600, 30)
if _, err := st.db.Exec(`INSERT INTO probe_setting (key, value) VALUES (?, ?)`,
rolledKey, h2+3600); err != nil {
t.Fatalf("set watermark: %v", err)
}
// One unfolded minute row in the current hour.
insertMinute(t, st, 9, hour+60, 90, 900, 5000, 5000, 90, 9)
sp := ComputeSpan(200, nil, now)
if !sp.Hourly || sp.Step != 3600 {
t.Fatalf("span: %+v", sp)
}
rows, err := st.MetricHistory(9, sp)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(rows) != 3 {
t.Fatalf("want 3 points (2 folded + 1 live), got %d (%+v)", len(rows), rows)
}
if rows[0].Ts != h1 || rows[0].Cpu != 30 || rows[0].Minutes != 60 || rows[0].MemUsed != 300 {
t.Fatalf("folded point 0 wrong: %+v", rows[0])
}
if rows[1].Ts != h2 || rows[1].Cpu != 60 || rows[1].Minutes != 30 {
t.Fatalf("folded point 1 wrong: %+v", rows[1])
}
if rows[2].Ts != hour || rows[2].Cpu != 90 || rows[2].Minutes != 1 || rows[2].NetRx != 5000 {
t.Fatalf("live point wrong: %+v", rows[2])
}
}
// Meta lifecycle: defaults for unknown agents, full round-trip,
// replace-on-upsert, and the delete cascade emptying every tier.
func TestMetaCRUDAndDelete(t *testing.T) {
st := newTestStore(t)
if _, ok, err := st.Meta(42); err != nil || ok {
t.Fatalf("unknown agent: ok=%v err=%v", ok, err)
}
d := DefaultMeta(42)
if d.Currency != "USD" || d.BillingCycle != "monthly" || !d.Public ||
d.TrafficMode != "sum" || d.TrafficResetDay != 1 || d.Notify {
t.Fatalf("defaults drifted from the DDL: %+v", d)
}
exp := "2030-01-02"
want := Meta{
AgentID: 42, Price: 12.5, Currency: "USD", BillingCycle: "yearly",
ExpiresAt: &exp, TrafficLimit: 1_000_000_000, TrafficMode: "max",
TrafficResetDay: 15, Public: false, Sort: 3, GroupName: "g1",
PublicRemark: "hello", Notify: true, CountryPin: "JP",
IPv4Pin: "203.0.113.7", IPv6Pin: "2001:db8::7",
}
if err := st.UpsertMeta(want); err != nil {
t.Fatalf("upsert: %v", err)
}
got, ok, err := st.Meta(42)
if err != nil || !ok {
t.Fatalf("read back: ok=%v err=%v", ok, err)
}
gotExp, wantExp := "", ""
if got.ExpiresAt != nil {
gotExp = *got.ExpiresAt
}
if want.ExpiresAt != nil {
wantExp = *want.ExpiresAt
}
got.ExpiresAt, want.ExpiresAt = nil, nil
if got != want || gotExp != wantExp {
t.Fatalf("round trip mismatch:\n got %+v exp=%q\nwant %+v exp=%q", got, gotExp, want, wantExp)
}
want.ExpiresAt = &exp
want.Price = 99
if err := st.UpsertMeta(want); err != nil {
t.Fatalf("replace: %v", err)
}
all, err := st.AllMeta()
if err != nil || len(all) != 1 || all[42].Price != 99 {
t.Fatalf("all meta: %v %+v", err, all)
}
insertMinute(t, st, 42, 1000, 1, 1, 1, 1, 1, 1)
if _, err := st.db.Exec(`INSERT INTO probe_traffic (agent_id) VALUES (42)`); err != nil {
t.Fatalf("seed traffic: %v", err)
}
if err := st.DeleteAgentData(42); err != nil {
t.Fatalf("delete: %v", err)
}
if _, ok, _ := st.Meta(42); ok {
t.Fatal("meta survived delete")
}
for _, table := range []string{"probe_metric", "probe_traffic"} {
var n int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM ` + table + ` WHERE agent_id = 42`).Scan(&n); err != nil || n != 0 {
t.Fatalf("%s survived delete: n=%d err=%v", table, n, err)
}
}
}
+539
View File
@@ -0,0 +1,539 @@
package service
import (
"bytes"
"compress/gzip"
"encoding/json"
"net"
"sort"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// Item 7 B3: the public status surface (monitor hub contract). The
// node views are assembled whitelist-style from three sources — the
// agent_nodes snapshot rows (facts + latest metrics, B1), the probe
// ledger/metadata (B2) and the bound panel node's name/remark — and
// rendered into per-audience snapshot frames shared by /api/nodes and
// /api/ws, exactly as the hub renders one frame per audience per tick.
// ProbeOnlineThresholdMs is how stale an agent's last heartbeat may be
// before the public page calls it offline. Heartbeats land every 60 s;
// 150 s absorbs one lost beat plus jitter.
const ProbeOnlineThresholdMs = 150_000
// ProbeSnapshotTTL mirrors the hub's 1900 ms frame reuse: just under
// the 2 s push interval, so a frame is built at most once per tick.
const ProbeSnapshotTTL = 1900 * time.Millisecond
// ---- public-surface settings (main settings KV, hub key names) ----
func probeSetting(key string) (string, bool) {
var row model.Setting
if err := database.GetDB().Where("key = ?", key).First(&row).Error; err != nil {
return "", false
}
return row.Value, true
}
func setProbeSetting(key, value string) error {
db := database.GetDB()
var row model.Setting
err := db.Where("key = ?", key).First(&row).Error
if database.IsNotFound(err) {
return db.Create(&model.Setting{Key: key, Value: value}).Error
}
if err != nil {
return err
}
row.Value = value
return db.Save(&row).Error
}
// ProbePublicPageEnabled reports the public-page master switch. Per
// the item-7 scope decision the page ships OFF until the operator
// turns it on (setting public_page = "on"); the hub's own default is
// on, the deviation is deliberate and recorded in the B3 report.
func ProbePublicPageEnabled() bool {
v, _ := probeSetting("public_page")
return v == "on"
}
// SetProbePublicPageEnabled flips the public-page master switch.
func SetProbePublicPageEnabled(on bool) error {
if on {
return setProbeSetting("public_page", "on")
}
return setProbeSetting("public_page", "off")
}
// ProbeSiteName is the status page's site name (hub default "Monitor").
func ProbeSiteName() string {
if v, ok := probeSetting("site_name"); ok && v != "" {
return v
}
return "Monitor"
}
// ProbeSiteURL is the hub's public URL setting ("site"), "" when unset.
func ProbeSiteURL() string {
v, _ := probeSetting("site")
return v
}
// ProbeGitHubConfigured mirrors /api/me's github flag: whether a
// github_client_id is configured. (GitHub login itself is not ported;
// the panel's own session is the only login.)
func ProbeGitHubConfigured() bool {
v, _ := probeSetting("github_client_id")
return strings.TrimSpace(v) != ""
}
// ProbeCurrentTheme is the selected theme short ("" = built-in default).
func ProbeCurrentTheme() string {
v, _ := probeSetting("theme")
return v
}
// ProbeThemeConfigValue reads a theme's saved config JSON verbatim
// (setting key theme_config:{short}), "{}" when never saved. A read
// failure is an error, not an empty config: a caller saving on top of
// what it read would otherwise erase every override (hub discipline).
func ProbeThemeConfigValue(short string) (string, error) {
var row model.Setting
err := database.GetDB().Where("key = ?", "theme_config:"+short).First(&row).Error
if database.IsNotFound(err) {
return "{}", nil
}
if err != nil {
return "", err
}
if row.Value == "" {
return "{}", nil
}
return row.Value, nil
}
// SaveProbeThemeConfigRaw stores a theme's config JSON verbatim.
func SaveProbeThemeConfigRaw(short, raw string) error {
return setProbeSetting("theme_config:"+short, raw)
}
// ---- node view assembly ----
type probeNodeSource struct {
agent model.AgentNode
name string
remark string
meta probe.Meta
traffic probe.Traffic
nowMs int64
now time.Time
online bool
lastSeen int64 // unix seconds, 0 = never seen
metricsOn bool
metricsObj map[string]any
}
func loadProbeNodeSources() []probeNodeSource {
now := time.Now()
nowMs := now.UnixMilli()
var agents []model.AgentNode
if err := database.GetDB().Order("id ASC").Find(&agents).Error; err != nil {
logger.Warningf("probe public: load agents: %v", err)
return nil
}
nodesByID := map[int]model.Node{}
var nodes []model.Node
if err := database.GetDB().Find(&nodes).Error; err == nil {
for _, n := range nodes {
nodesByID[n.Id] = n
}
}
metas, err := probe.MetaSnapshot()
if err != nil {
logger.Warningf("probe public: load meta: %v", err)
metas = map[int64]probe.Meta{}
}
traffic, err := probe.TrafficSnapshot()
if err != nil {
logger.Warningf("probe public: load traffic: %v", err)
traffic = map[int64]probe.Traffic{}
}
out := make([]probeNodeSource, 0, len(agents))
for i := range agents {
a := agents[i]
src := probeNodeSource{agent: a, name: a.Name, nowMs: nowMs, now: now}
if a.NodeID != nil {
if n, ok := nodesByID[*a.NodeID]; ok {
if n.Name != "" {
src.name = n.Name
}
src.remark = n.Remark
}
}
if m, ok := metas[int64(a.Id)]; ok {
src.meta = m
} else {
src.meta = probe.DefaultMeta(int64(a.Id))
}
src.traffic = traffic[int64(a.Id)]
src.online = a.LastSeen > 0 && nowMs-a.LastSeen <= ProbeOnlineThresholdMs
src.lastSeen = a.LastSeen / 1000
if src.online && a.ProbeAt > 0 {
src.metricsOn = true
src.metricsObj = probeLiveMetrics(&a, src.traffic)
}
out = append(out, src)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].meta.Sort != out[j].meta.Sort {
return out[i].meta.Sort < out[j].meta.Sort
}
return out[i].agent.Id < out[j].agent.Id
})
return out
}
// probeLiveMetrics builds the whitelisted live metrics object
// (hub PUBLIC_METRICS) from the latest snapshot, with the four ledger
// figures injected (hub INJECTED — they never arrive from an agent).
func probeLiveMetrics(a *model.AgentNode, t probe.Traffic) map[string]any {
return map[string]any{
"uptime": a.UptimeSecs,
"cpu": float64(a.ProbeCPU),
"load": []float64{float64(a.ProbeLoad1), float64(a.ProbeLoad5), float64(a.ProbeLoad15)},
"mem_total": a.ProbeMemTotal,
"mem_used": a.ProbeMemUsed,
"swap_total": a.ProbeSwapTotal,
"swap_used": a.ProbeSwapUsed,
"disk_total": a.ProbeDiskTotal,
"disk_used": a.ProbeDiskUsed,
"net_rx": a.ProbeNetRx,
"net_tx": a.ProbeNetTx,
"tcp": a.ProbeTCP,
"udp": a.ProbeUDP,
"procs": a.ProbeProcs,
"total_rx": t.TotalRx,
"total_tx": t.TotalTx,
"month_rx": t.MonthRx,
"month_tx": t.MonthTx,
}
}
// expiresInDays counts whole calendar days from the local today to
// the expires date, as the hub does on its own calendar.
func expiresInDays(expires *string, now time.Time) any {
if expires == nil {
return nil
}
d, err := time.ParseInLocation("2006-01-02", *expires, time.Local)
if err != nil {
return nil
}
ny, nm, nd := now.Date()
ey, em, ed := d.Date()
days := int(time.Date(ey, em, ed, 0, 0, 0, 0, time.UTC).Unix()/86400 -
time.Date(ny, nm, nd, 0, 0, 0, 0, time.UTC).Unix()/86400)
return days
}
// buildProbeNodeView renders one node for one audience. The public
// shape is built by whitelist (hub node_view): address, hostname and
// the private remark exist only in the admin (full) rendering.
func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
a := src.agent
m := src.meta
t := src.traffic
var lastSeenAgo any
if src.lastSeen > 0 {
ago := src.now.Unix() - src.lastSeen
if ago < 0 {
ago = 0
}
lastSeenAgo = ago
}
var metrics any
if src.metricsOn {
obj := make(map[string]any, len(src.metricsObj)+1)
for k, v := range src.metricsObj {
obj[k] = v
}
if full {
obj["iface"] = a.ProbeIface
}
metrics = obj
}
view := map[string]any{
"id": a.Id,
"name": src.name,
"country": m.CountryPin,
"group": m.GroupName,
"public_remark": m.PublicRemark,
"sort": m.Sort,
"public": m.Public,
"online": src.online,
"last_seen": src.lastSeen,
"last_seen_ago": lastSeenAgo,
"metrics": metrics,
"os": a.ProbeOS,
"kernel": a.ProbeKernel,
"arch": a.ProbeArch,
"virt": a.ProbeVirt,
"cpu_name": a.ProbeCPUName,
"cpu_cores": a.ProbeCPUCores,
"mem_total": a.ProbeMemTotal,
"swap_total": a.ProbeSwapTotal,
"disk_total": a.ProbeDiskTotal,
"agent_version": a.AgentVersion,
"price": m.Price,
"currency": m.Currency,
"billing_cycle": m.BillingCycle,
"expires_at": m.ExpiresAt,
"expires_in": expiresInDays(m.ExpiresAt, src.now),
"traffic_limit": m.TrafficLimit,
"traffic_mode": m.TrafficMode,
"traffic_reset_day": m.TrafficResetDay,
"total_rx": t.TotalRx,
"total_tx": t.TotalTx,
"month_rx": t.MonthRx,
"month_tx": t.MonthTx,
"month_used": t.MonthUsed(m.TrafficMode),
"month_start": t.MonthStart,
"day_rx": t.DayRx,
"day_tx": t.DayTx,
}
if !full {
return view
}
// Admin-only half: identity and addresses never leave the panel
// in the anonymous rendering (they are simply never built there).
held4, held6 := a.IPv4, a.IPv6
exits := []string{a.SeenIPv4, a.SeenIPv6}
shown := probeAddresses(exits, held4, held6, m.IPv4Pin, m.IPv6Pin)
addrList := make([]map[string]string, 0, len(shown))
for _, s := range shown {
addrList = append(addrList, map[string]string{"address": s.addr, "source": s.source})
}
ip := ""
for _, e := range exits {
if e != "" {
ip = e
break
}
}
var interval any
if src.online {
interval = 60 // heartbeat cadence is fixed at 60 s by design
}
view["hostname"] = a.ProbeHostname
view["ip"] = ip
view["ipv4"] = held4
view["ipv6"] = held6
view["ipv4_pin"] = m.IPv4Pin
view["ipv6_pin"] = m.IPv6Pin
view["addresses"] = addrList
view["ipv4_auto"] = probeAutoAddress(exits, held4, held6, "", m.IPv6Pin, false)
view["ipv6_auto"] = probeAutoAddress(exits, held4, held6, m.IPv4Pin, "", true)
view["interval"] = interval
view["country_pin"] = m.CountryPin
view["country_auto"] = ""
view["remark"] = src.remark
view["notify"] = m.Notify
return view
}
type probeShownAddr struct {
addr string
source string
}
// probeAddresses is the hub's addresses() port: per family a manual
// pin wins, then a public interface address, then — when the interface
// holds only a private address of that family — the public exit the
// master observed. With nothing public anywhere it falls back to the
// held interface addresses, then to the connection source.
func probeAddresses(exits []string, held4, held6, pin4, pin6 string) []probeShownAddr {
public := func(s string, v6 bool) bool {
ip := net.ParseIP(s)
if ip == nil || (ip.To4() == nil) != v6 {
return false
}
return IsPublicIPLiteral(s)
}
family := func(pin, held string, v6 bool) *probeShownAddr {
if pin != "" {
return &probeShownAddr{pin, "manual"}
}
if public(held, v6) {
return &probeShownAddr{held, "interface"}
}
if held == "" {
return nil
}
for _, e := range exits {
if public(e, v6) {
return &probeShownAddr{e, "exit"}
}
}
return nil
}
var shown []probeShownAddr
if s := family(pin4, held4, false); s != nil {
shown = append(shown, *s)
}
if s := family(pin6, held6, true); s != nil {
shown = append(shown, *s)
}
if len(shown) > 0 {
return shown
}
for _, h := range []string{held4, held6} {
if h != "" {
shown = append(shown, probeShownAddr{h, "interface"})
}
}
if len(shown) > 0 {
return shown
}
for _, e := range exits {
if e != "" {
return []probeShownAddr{{e, "connection"}}
}
}
return nil
}
// probeAutoAddress recomputes one family's shown address with its own
// pin cleared (the edit form's fallback hint, hub ipv4_auto/ipv6_auto).
func probeAutoAddress(exits []string, held4, held6, pin4, pin6 string, v6 bool) string {
for _, s := range probeAddresses(exits, held4, held6, pin4, pin6) {
if strings.Contains(s.addr, ":") == v6 {
return s.addr
}
}
return ""
}
// BuildProbeNodeViews renders every node for one audience; anonymous
// renders only public=1 nodes.
func BuildProbeNodeViews(full bool) []map[string]any {
sources := loadProbeNodeSources()
views := make([]map[string]any, 0, len(sources))
for _, src := range sources {
if !full && !src.meta.Public {
continue
}
views = append(views, buildProbeNodeView(src, full))
}
return views
}
// ---- snapshot frames (shared by /api/nodes and /api/ws) ----
type probeFrame struct {
at time.Time
body []byte
gz []byte
}
var probeSnapshots struct {
mu sync.Mutex
frames [2]probeFrame
}
// ProbeSnapshotJSON returns the audience's rendered snapshot frame,
// rebuilding it at most once per ProbeSnapshotTTL.
func ProbeSnapshotJSON(full bool) []byte {
probeSnapshots.mu.Lock()
defer probeSnapshots.mu.Unlock()
idx := 0
if full {
idx = 1
}
f := &probeSnapshots.frames[idx]
if f.body == nil || time.Since(f.at) < 0 || time.Since(f.at) >= ProbeSnapshotTTL {
frame := struct {
Nodes []map[string]any `json:"nodes"`
Admin bool `json:"admin"`
}{Nodes: BuildProbeNodeViews(full), Admin: full}
body, err := json.Marshal(frame)
if err != nil {
body = []byte(`{"nodes":[],"admin":false}`)
}
f.at = time.Now()
f.body = body
f.gz = nil
}
return f.body
}
// ProbeSnapshotGzip is the anonymous frame gzipped, built lazily once
// per frame (hub live_gzip) for /api/ws ?gzip binary frames and for
// pre-compressed /api/nodes answers.
func ProbeSnapshotGzip() []byte {
body := ProbeSnapshotJSON(false)
probeSnapshots.mu.Lock()
defer probeSnapshots.mu.Unlock()
f := &probeSnapshots.frames[0]
if f.gz == nil {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
_, _ = w.Write(body)
_ = w.Close()
f.gz = buf.Bytes()
}
return f.gz
}
// InvalidateProbeSnapshot drops both cached frames so the next read
// rebuilds (called after metadata edits, as the hub does on node
// writes).
func InvalidateProbeSnapshot() {
probeSnapshots.mu.Lock()
defer probeSnapshots.mu.Unlock()
probeSnapshots.frames = [2]probeFrame{}
}
// ProbeNodeReadable mirrors the hub's readable(): the panel sees
// every node; an anonymous caller only public=1 nodes, and only while
// the public page is on. As on the hub, an authed caller may read a
// window for a node that does not exist (it comes back empty); the
// anonymous side needs the agent to exist with the public flag set,
// consistent with what /api/nodes lists.
func ProbeNodeReadable(full bool, agentID int64) bool {
if full {
return true
}
if !ProbePublicPageEnabled() || !ProbeAgentExists(agentID) {
return false
}
meta, ok, err := probe.MetaFor(agentID)
if err != nil {
return false
}
if !ok {
return probe.DefaultMeta(agentID).Public
}
return meta.Public
}
// ProbeAgentExists reports whether an agent row exists.
func ProbeAgentExists(agentID int64) bool {
var count int64
if err := database.GetDB().Model(&model.AgentNode{}).Where("id = ?", agentID).Count(&count).Error; err != nil {
return false
}
return count > 0
}
+158
View File
@@ -0,0 +1,158 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/gin-gonic/gin"
)
// Item 7 B3: public theme hosting (hub frontend.rs serve/disk/asset
// port). Themes are static directory packages on disk —
// <db folder>/themes/<short>/dist — served by the panel process
// itself at the engine root, next to the contract API. A theme's
// files are only ever read as static bytes, never executed. The
// built-in fallback theme (hub embeds one) lands in B5 with the
// theme store; until then a path no installed theme answers is a 404.
// ProbeThemesRoot is the on-disk themes directory.
func ProbeThemesRoot() string {
return filepath.Join(config.GetDBFolderPath(), "themes")
}
// ValidThemeShort mirrors the hub's valid_short: the name of a theme
// directory, so no separators and nothing empty ([A-Za-z0-9_-]+).
func ValidThemeShort(short string) bool {
if short == "" {
return false
}
for _, r := range short {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
return false
}
}
return true
}
// probeExternalTheme resolves the dist directory of an installed
// theme (hub external_theme): the theme counts only when dist and
// its index.html are actually there.
func probeExternalTheme(short string) (string, bool) {
if short == "" {
short = "default"
}
if !ValidThemeShort(short) {
return "", false
}
themes, err := filepath.EvalSymlinks(ProbeThemesRoot())
if err != nil {
return "", false
}
root, err := filepath.EvalSymlinks(filepath.Join(themes, short))
if err != nil || !strings.HasPrefix(root, themes+string(os.PathSeparator)) {
return "", false
}
dist, err := filepath.EvalSymlinks(filepath.Join(root, "dist"))
if err != nil || !strings.HasPrefix(dist, root+string(os.PathSeparator)) {
return "", false
}
info, err := os.Stat(filepath.Join(dist, "index.html"))
if err != nil || info.IsDir() {
return "", false
}
return dist, true
}
// ProbeThemeInstalled reports whether a theme short is selectable
// (hub selectable): an installed external theme.
func ProbeThemeInstalled(short string) bool {
_, ok := probeExternalTheme(short)
return ok
}
// readInsideTheme reads a regular file whose canonical path stays
// below root (hub read_inside), rejecting escapes and symlinks out.
func readInsideTheme(root, relative string) ([]byte, bool) {
canonRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, false
}
file, err := filepath.EvalSymlinks(filepath.Join(canonRoot, relative))
if err != nil || !strings.HasPrefix(file, canonRoot+string(os.PathSeparator)) {
return nil, false
}
info, err := os.Stat(file)
if err != nil || !info.Mode().IsRegular() {
return nil, false
}
data, err := os.ReadFile(file)
if err != nil {
return nil, false
}
return data, true
}
func isThemeAssetPath(p string) bool { return strings.HasPrefix(p, "assets/") }
// serveThemeAsset writes one theme file with the caching policy its
// path warrants (hub asset()): hashed files under assets/ are
// immutable for a year; everything else is the SPA shell — no-cache
// plus a strong ETag (half the SHA-256 of the body), so a theme
// switch lands on the next request at the cost of a 304.
func serveThemeAsset(c *gin.Context, path string, data []byte) {
contentType := mime.TypeByExtension(filepath.Ext(path))
if contentType == "" {
contentType = "application/octet-stream"
}
if isThemeAssetPath(path) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, contentType, data)
return
}
sum := sha256.Sum256(data)
etag := "\"" + hex.EncodeToString(sum[:])[:32] + "\""
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "no-cache")
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.AbortWithStatus(http.StatusNotModified)
return
}
c.Data(http.StatusOK, contentType, data)
}
// ServeProbeThemePath answers one non-API root path from the current
// theme (hub respond/disk): the file itself when it exists, the
// theme's index.html as the SPA fallback for non-asset paths, and a
// true 404 for missing hashed assets. Returns false when no theme is
// installed to answer at all (caller emits its own 404).
func ServeProbeThemePath(c *gin.Context, path string) bool {
dist, ok := probeExternalTheme(ProbeCurrentTheme())
if !ok {
return false
}
rel := path
if rel == "" {
rel = "index.html"
}
if data, found := readInsideTheme(dist, rel); found {
serveThemeAsset(c, rel, data)
return true
}
if isThemeAssetPath(rel) {
c.String(http.StatusNotFound, "没有这个文件:/%s", rel)
return true
}
if data, found := readInsideTheme(dist, "index.html"); found {
serveThemeAsset(c, "index.html", data)
return true
}
return false
}
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "افتراضي",
+34 -1
View File
@@ -1066,7 +1066,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "Default",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "Predeterminado",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "پیش‌فرض",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "Bawaan",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "デフォルト",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "Padrão",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "По умолчанию",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "Varsayılan",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "Типово",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "Mặc định",
+34 -1
View File
@@ -948,7 +948,40 @@
"agentTokenCopyFailed": "复制失败,请手动复制",
"copyToken": "复制安装命令",
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):",
"agentMasterUrlRequired": "主控地址为空——请先填入主控地址,之后才会生成安装命令。"
"agentMasterUrlRequired": "主控地址为空——请先填入主控地址,之后才会生成安装命令。",
"probe": {
"title": "公开状态页",
"hint": "公开状态页的展示与套餐信息,保存后立即生效。",
"public": "在公开页显示",
"publicRemark": "公开备注",
"group": "分组",
"sort": "排序",
"price": "价格",
"currency": "货币",
"billingCycle": "付款周期",
"cycleOnce": "一次性",
"cycleMonthly": "每月",
"cycleQuarterly": "每季",
"cycleSemiannual": "每半年",
"cycleYearly": "每年",
"cycleBiennial": "每两年",
"cycleTriennial": "每三年",
"expiresAt": "到期日期(YYYY-MM-DD)",
"trafficLimit": "流量上限(GB,0 = 不限)",
"trafficMode": "流量统计口径",
"modeSum": "上行 + 下行",
"modeUp": "只算上行",
"modeDown": "只算下行",
"modeMax": "取上下行较大值",
"trafficResetDay": "流量重置日",
"notify": "到期 / 流量提醒",
"countryPin": "国家锁定(如 JP)",
"ipv4Pin": "IPv4 锁定",
"ipv6Pin": "IPv6 锁定",
"save": "保存状态页信息",
"saved": "状态页信息已保存",
"saveFailed": "状态页信息保存失败"
}
},
"settings": {
"defaultTag": "默认",
+34 -1
View File
@@ -926,7 +926,40 @@
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
"probe": {
"title": "Public status page",
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
"public": "Show on public page",
"publicRemark": "Public remark",
"group": "Group",
"sort": "Sort order",
"price": "Price",
"currency": "Currency",
"billingCycle": "Billing cycle",
"cycleOnce": "One-time",
"cycleMonthly": "Monthly",
"cycleQuarterly": "Quarterly",
"cycleSemiannual": "Semiannual",
"cycleYearly": "Yearly",
"cycleBiennial": "Biennial",
"cycleTriennial": "Triennial",
"expiresAt": "Expires on (YYYY-MM-DD)",
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
"trafficMode": "Traffic counting",
"modeSum": "Up + down",
"modeUp": "Up only",
"modeDown": "Down only",
"modeMax": "Larger of up/down",
"trafficResetDay": "Traffic reset day",
"notify": "Expiry / traffic alerts",
"countryPin": "Country pin (e.g. JP)",
"ipv4Pin": "IPv4 pin",
"ipv6Pin": "IPv6 pin",
"save": "Save status page info",
"saved": "Status page info saved",
"saveFailed": "Saving status page info failed"
}
},
"settings": {
"defaultTag": "預設",
+14
View File
@@ -112,6 +112,7 @@ type Server struct {
index *controller.IndexController
panel *controller.XUIController
probe *controller.PublicProbeController
api *controller.APIController
ws *controller.WebSocketController
@@ -251,6 +252,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g)
// Item 7 B3: the monitor contract API + public theme hosting live
// at the engine root, outside the basePath group (themes address
// /api/... root-relative; see controller/probe_public.go). Panel
// session cookies cover the root too, so admin rendering works
// from the same login.
s.probe = controller.NewPublicProbeController(engine)
// Initialize WebSocket hub
s.wsHub = websocket.NewHub()
go s.wsHub.Run()
@@ -269,6 +277,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
// Let unknown panel document routes fall back to the SPA shell, while every
// non-SPA miss still returns a hard 404.
engine.NoRoute(func(c *gin.Context) {
// The public probe surface answers first for paths outside the
// panel basePath (theme hosting + /api 404s); panel paths keep
// their SPA fallback.
if s.probe != nil && s.probe.HandleFallback(c) {
return
}
if s.panel.HandleNoRoutePanelSPA(c) {
return
}