Files
3x-ui-agent/internal/web/controller/probe_meta.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

281 lines
8.1 KiB
Go

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"`
// V2.0.1: traffic calibration (GB, either sign) and the counted
// interface pin ("" = automatic).
TrafficCalUpGB float64 `json:"trafficCalUpGb"`
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
StatIface string `json:"statIface"`
}
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,
TrafficCalUpGB: m.TrafficCalUpGB,
TrafficCalDownGB: m.TrafficCalDownGB,
StatIface: m.StatIface,
}
}
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,
TrafficCalUpGB: f.TrafficCalUpGB,
TrafficCalDownGB: f.TrafficCalDownGB,
StatIface: f.StatIface,
}
}
// 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 "价格和流量上限不能是负数"
}
if math.IsNaN(f.TrafficCalUpGB) || math.IsInf(f.TrafficCalUpGB, 0) ||
math.IsNaN(f.TrafficCalDownGB) || math.IsInf(f.TrafficCalDownGB, 0) {
return "流量校正要填有限的数字(GB,可正可负)"
}
f.StatIface = strings.TrimSpace(f.StatIface)
if len(f.StatIface) > 32 || hasControl(f.StatIface) || strings.ContainsAny(f.StatIface, " \t/") {
return "统计网卡填网卡名(如 eth0),留空则自动甄别"
}
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
}