From 540c05b0a5b8c15a2be923a509eb512c1dcdaed2 Mon Sep 17 00:00:00 2001 From: Muse Date: Thu, 22 Oct 2026 21:39:06 +0000 Subject: [PATCH] feat(probe): public contract API + theme hosting + meta editing (item 7 B3) --- frontend/src/pages/nodes/NodeFormModal.tsx | 5 + frontend/src/pages/nodes/ProbeMetaSection.tsx | 262 ++++++++ frontend/src/utils/index.ts | 29 + internal/web/controller/agent.go | 7 + internal/web/controller/api.go | 3 + internal/web/controller/probe_meta.go | 261 ++++++++ internal/web/controller/probe_public.go | 362 +++++++++++ internal/web/controller/probe_public_test.go | 592 ++++++++++++++++++ internal/web/service/probe/meta.go | 152 +++++ internal/web/service/probe/probe.go | 53 ++ internal/web/service/probe/read.go | 173 +++++ internal/web/service/probe/read_test.go | 227 +++++++ internal/web/service/probe_public.go | 539 ++++++++++++++++ internal/web/service/probe_theme.go | 158 +++++ internal/web/translation/ar-EG.json | 35 +- internal/web/translation/en-US.json | 35 +- internal/web/translation/es-ES.json | 35 +- internal/web/translation/fa-IR.json | 35 +- internal/web/translation/id-ID.json | 35 +- internal/web/translation/ja-JP.json | 35 +- internal/web/translation/pt-BR.json | 35 +- internal/web/translation/ru-RU.json | 35 +- internal/web/translation/tr-TR.json | 35 +- internal/web/translation/uk-UA.json | 35 +- internal/web/translation/vi-VN.json | 35 +- internal/web/translation/zh-CN.json | 35 +- internal/web/translation/zh-TW.json | 35 +- internal/web/web.go | 14 + 28 files changed, 3279 insertions(+), 13 deletions(-) create mode 100644 frontend/src/pages/nodes/ProbeMetaSection.tsx create mode 100644 internal/web/controller/probe_meta.go create mode 100644 internal/web/controller/probe_public.go create mode 100644 internal/web/controller/probe_public_test.go create mode 100644 internal/web/service/probe/meta.go create mode 100644 internal/web/service/probe/read.go create mode 100644 internal/web/service/probe/read_test.go create mode 100644 internal/web/service/probe_public.go create mode 100644 internal/web/service/probe_theme.go diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index ab583a8..973919c 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -21,6 +21,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; import { currentPanelMasterUrl } from './agentMasterUrl'; +import ProbeMetaSection from './ProbeMetaSection'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; @@ -472,6 +473,10 @@ export default function NodeFormModal({ )} + {mode === 'edit' && isAgent && node?.id ? ( + + ) : null} + {!isAgent && ( +
{label}
+ {children} + + ); +} + +export default function ProbeMetaSection({ nodeId }: { nodeId: number }) { + const { t } = useTranslation(); + const [messageApi, messageContextHolder] = message.useMessage(); + // Loaded meta, keyed by node id so a stale result for a previously + // edited node is never shown (same discipline as the install block + // in NodeFormModal); visibility derives during render. + const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null); + const [trafficGb, setTrafficGb] = useState(0); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + HttpUtil.get(`/panel/api/agent/probe-meta/${nodeId}`, { + silent: true, + }).then((msg) => { + if (!cancelled && msg?.success && msg.obj) { + setLoaded({ nodeId, meta: msg.obj }); + setTrafficGb(msg.obj.trafficLimit / 1e9); + } + }); + return () => { + cancelled = true; + }; + }, [nodeId]); + + const meta = loaded && loaded.nodeId === nodeId ? loaded.meta : null; + if (!meta) return null; + + const set = (key: K, value: ProbeMetaWire[K]) => + setLoaded((l) => (l ? { nodeId: l.nodeId, meta: { ...l.meta, [key]: value } } : l)); + + const cycleLabel = (cycle: string) => { + switch (cycle) { + case 'once': + return t('pages.nodes.probe.cycleOnce'); + case 'monthly': + return t('pages.nodes.probe.cycleMonthly'); + case 'quarterly': + return t('pages.nodes.probe.cycleQuarterly'); + case 'semiannual': + return t('pages.nodes.probe.cycleSemiannual'); + case 'yearly': + return t('pages.nodes.probe.cycleYearly'); + case 'biennial': + return t('pages.nodes.probe.cycleBiennial'); + case 'triennial': + return t('pages.nodes.probe.cycleTriennial'); + default: + return cycle; + } + }; + const cycleOptions: { value: string; label: string }[] = NAMED_CYCLES.map((cycle) => ({ + value: cycle, + label: cycleLabel(cycle), + })); + if (!NAMED_CYCLES.includes(meta.billingCycle as (typeof NAMED_CYCLES)[number])) { + cycleOptions.push({ value: meta.billingCycle, label: meta.billingCycle }); + } + + async function onSave() { + if (!meta) return; + setSaving(true); + try { + const payload: ProbeMetaWire = { + ...meta, + trafficLimit: Math.round(trafficGb * 1e9), + }; + const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, { + silent: true, + }); + if (msg?.success) { + setLoaded({ nodeId, meta: payload }); + messageApi.success(t('pages.nodes.probe.saved')); + } else { + messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed')); + } + } finally { + setSaving(false); + } + } + + return ( +
+ {messageContextHolder} +
{t('pages.nodes.probe.title')}
+

{t('pages.nodes.probe.hint')}

+ + + + set('public', v)} /> + + + + + set('notify', v)} /> + + + + + + + set('group', e.target.value)} /> + + + + + set('sort', v ?? 0)} + style={{ width: '100%' }} + /> + + + + + set('publicRemark', e.target.value)} /> + + + + + set('price', v ?? 0)} + style={{ width: '100%' }} + /> + + + + + set('currency', e.target.value)} /> + + + + + set('expiresAt', e.target.value || null)} + /> + + + + + set('trafficResetDay', v ?? 1)} + style={{ width: '100%' }} + /> + + + + + + + setTrafficGb(v ?? 0)} + style={{ width: '100%' }} + /> + + + + + set('countryPin', e.target.value)} /> + + + + + set('ipv4Pin', e.target.value)} /> + + + + + set('ipv6Pin', e.target.value)} /> + + + + +
+ ); +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index ea991ee..9e4d8ec 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -125,6 +125,35 @@ export class HttpUtil { } } + static async put( + url: string, + data?: unknown, + options: HttpOptions = {}, + ): Promise> { + const { silent, silentSuccess, ...rest } = options; + try { + const resp = await httpRequest('PUT', url, data, rest); + const msg = this._respToMsg(resp) as Msg; + if (!silent) this._handleMsg(msg, silentSuccess); + return msg; + } catch (error) { + console.error('PUT request failed:', error); + const err = error as { + response?: { data?: { msg?: string; message?: string } }; + message?: string; + }; + const data = err.response?.data; + const errorMsg = new Msg( + false, + data?.msg || data?.message || err.message || 'Request failed', + ); + if (!silent) { + this._handleMsg(errorMsg); + } + return errorMsg; + } + } + static async delete(url: string, options: HttpOptions = {}): Promise> { const { silent, silentSuccess, ...rest } = options; try { diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index 7d66570..046580c 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -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) } diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 32ca73b..a1d358d 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -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") diff --git a/internal/web/controller/probe_meta.go b/internal/web/controller/probe_meta.go new file mode 100644 index 0000000..42406f4 --- /dev/null +++ b/internal/web/controller/probe_meta.go @@ -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 +} diff --git a/internal/web/controller/probe_public.go b/internal/web/controller/probe_public.go new file mode 100644 index 0000000..83acf74 --- /dev/null +++ b/internal/web/controller/probe_public.go @@ -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, "/")) +} diff --git a/internal/web/controller/probe_public_test.go b/internal/web/controller/probe_public_test.go new file mode 100644 index 0000000..a7eeab4 --- /dev/null +++ b/internal/web/controller/probe_public_test.go @@ -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 := "theme" + 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) +} diff --git a/internal/web/service/probe/meta.go b/internal/web/service/probe/meta.go new file mode 100644 index 0000000..4ab6d17 --- /dev/null +++ b/internal/web/service/probe/meta.go @@ -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, ¬ify, &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() +} diff --git a/internal/web/service/probe/probe.go b/internal/web/service/probe/probe.go index abf0c67..5522ae1 100644 --- a/internal/web/service/probe/probe.go +++ b/internal/web/service/probe/probe.go @@ -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) + } +} diff --git a/internal/web/service/probe/read.go b/internal/web/service/probe/read.go new file mode 100644 index 0000000..a974faf --- /dev/null +++ b/internal/web/service/probe/read.go @@ -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=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() +} diff --git a/internal/web/service/probe/read_test.go b/internal/web/service/probe/read_test.go new file mode 100644 index 0000000..33b2c9a --- /dev/null +++ b/internal/web/service/probe/read_test.go @@ -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) + } + } +} diff --git a/internal/web/service/probe_public.go b/internal/web/service/probe_public.go new file mode 100644 index 0000000..222eade --- /dev/null +++ b/internal/web/service/probe_public.go @@ -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 +} diff --git a/internal/web/service/probe_theme.go b/internal/web/service/probe_theme.go new file mode 100644 index 0000000..ea6ad09 --- /dev/null +++ b/internal/web/service/probe_theme.go @@ -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 — +// /themes//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 +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 77443d1..0193ad8 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -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": "افتراضي", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index a23cdfc..53ad2e5 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 114024f..502a9ea 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 86e0c00..3831b8e 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -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": "پیش‌فرض", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 8f78b7f..431de16 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 785d165..557a717 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -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": "デフォルト", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 00f4075..6a4ccc3 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index da0d943..ab1dcda 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -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": "По умолчанию", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 901cb4f..946e2c4 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 8fd25d4..72a85c7 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -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": "Типово", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 5e076db..cdb4854 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index e26d3d4..bb903cc 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -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": "默认", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 12bd300..0ad20e1 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -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": "預設", diff --git a/internal/web/web.go b/internal/web/web.go index 65997ca..e915a69 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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 }