buildProbeNodeView now sources its 'name' from probeDisplayName: the node's public remark (probe_meta.public_remark) when non-empty after trimming, else the node name as before. Contract field name unchanged, theme packages untouched, both audiences (anonymous and admin frames share the builder); the panel's internal node list is a different surface and keeps the plain node name. Tests: display-name unit cases (remark / empty / whitespace) and view-level assertions for both audiences; the public contract test's seeded expectation updated to the remark (its fixture sets public_remark='shown publicly' on node 'PubNode').
575 lines
16 KiB
Go
575 lines
16 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/gzip"
|
||
"encoding/json"
|
||
"net"
|
||
"sort"
|
||
"strconv"
|
||
"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 frame reuse, shortened in
|
||
// V2.0.3 from the hub's 1900 ms: just under the fastest (1 s) push
|
||
// cadence, so a 1 s stream still gets a fresh frame every tick
|
||
// instead of repeating the previous one half the time.
|
||
const ProbeSnapshotTTL = 900 * 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
|
||
}
|
||
|
||
// ProbeFastIntervalSec is the agent fast-report cadence in seconds
|
||
// (V2.0.1 probe batch): runtime metrics ride a lightweight report at
|
||
// this interval instead of the 60 s heartbeat. Default 3, operator
|
||
// range 1–60 (setting key probe_fast_interval).
|
||
func ProbeFastIntervalSec() int {
|
||
v, ok := probeSetting("probe_fast_interval")
|
||
if !ok {
|
||
return 3
|
||
}
|
||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||
if err != nil || n < 1 {
|
||
return 3
|
||
}
|
||
if n > 60 {
|
||
return 60
|
||
}
|
||
return n
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// probeDisplayName is the name a node carries in the public views
|
||
// (V2.2.1 ⑦): the operator's public remark when one is set — it is
|
||
// the label written for public consumption — else the node name. The
|
||
// contract field stays "name"; themes and the public API keep their
|
||
// shape, only the value's source changes. The panel's internal node
|
||
// list is a different surface and keeps the plain node name.
|
||
func probeDisplayName(name, publicRemark string) string {
|
||
if r := strings.TrimSpace(publicRemark); r != "" {
|
||
return r
|
||
}
|
||
return name
|
||
}
|
||
|
||
// 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": probeDisplayName(src.name, m.PublicRemark),
|
||
"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
|
||
}
|