V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
- 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).
This commit is contained in:
@@ -1303,6 +1303,13 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
postCommitApply()
|
||||
}
|
||||
|
||||
// V2.0.1 ⓪: a newly created inbound starts with no sing-box
|
||||
// fragment at all (fragments were a migration-era snapshot);
|
||||
// compute its first one from the saved row so an agent-bound
|
||||
// inbound is servable from birth, under the same discipline as
|
||||
// the UpdateInbound recompute.
|
||||
recomputeSingboxFragmentLogged(inbound)
|
||||
|
||||
// A routed mtproto inbound is not an Xray inbound itself, so the runtime
|
||||
// push above only (re)starts the mtg sidecar. The egress SOCKS bridge lives
|
||||
// in the generated config, so force a regen to wire it in.
|
||||
@@ -1950,6 +1957,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
if postCommitApply != nil {
|
||||
postCommitApply()
|
||||
}
|
||||
// V2.0.1 ⓪: refresh the stored sing-box fragment from the saved
|
||||
// row so agent-bound inbounds serve the edit, not the
|
||||
// migration-day snapshot. oldInbound carries the persisted values
|
||||
// (resolved tag, target node, normalized settings).
|
||||
recomputeSingboxFragmentLogged(oldInbound)
|
||||
return inbound, needRestart, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ func TestAnnotateAgentTelemetry(t *testing.T) {
|
||||
TokenHash: "hash-reporting",
|
||||
Name: "agent-node", NodeID: &agentNode.Id,
|
||||
AgentVersion: "3.7.0-agent.1", XrayVersion: "26.6.27",
|
||||
UptimeSecs: 90061, LatencyMs: 12, CpuPct: 3.5, MemPct: 41.2,
|
||||
SingboxVersion: "1.13.21",
|
||||
UptimeSecs: 90061, LatencyMs: 12, CpuPct: 3.5, MemPct: 41.2,
|
||||
IPv4: "203.0.113.7", IPv6: "2001:db8::7", TelemetryAt: 1700000000000,
|
||||
}
|
||||
silent := &model.AgentNode{TokenHash: "hash-silent", Name: "silent-agent-node", NodeID: &silentNode.Id}
|
||||
@@ -53,7 +54,7 @@ func TestAnnotateAgentTelemetry(t *testing.T) {
|
||||
if av.NodeType != "agent" {
|
||||
t.Fatalf("node 7401 type = %q, want agent", av.NodeType)
|
||||
}
|
||||
if av.XrayVersion != "26.6.27" || av.PanelVersion != "agent 3.7.0-agent.1" ||
|
||||
if av.XrayVersion != "26.6.27" || av.SingboxVersion != "1.13.21" || av.PanelVersion != "agent 3.7.0-agent.1" ||
|
||||
av.UptimeSecs != 90061 || av.LatencyMs != 12 ||
|
||||
av.CpuPct != 3.5 || av.MemPct != 41.2 ||
|
||||
av.Ipv4 != "203.0.113.7" || av.Ipv6 != "2001:db8::7" {
|
||||
|
||||
@@ -38,35 +38,40 @@ type NodeView struct {
|
||||
// The nodes page renders them as plain text on agent rows instead
|
||||
// of the panel-URL link used for traditional nodes. Empty for
|
||||
// traditional nodes and for agents with no known address yet.
|
||||
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
|
||||
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
||||
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
|
||||
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
||||
// Agent one-click update bookkeeping (item 20), agent nodes only:
|
||||
// the last intent's state/target and, on failure, its error.
|
||||
AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"`
|
||||
AgentUpdateError string `json:"agentUpdateError,omitempty" example:""`
|
||||
AgentUpdateTarget string `json:"agentUpdateTarget,omitempty" example:"A1.0.1"`
|
||||
Guid string `json:"guid" example:"node-guid"`
|
||||
Status string `json:"status" example:"online"`
|
||||
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
|
||||
LatencyMs int `json:"latencyMs" example:"42"`
|
||||
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
||||
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
|
||||
CpuPct float64 `json:"cpuPct" example:"12.5"`
|
||||
MemPct float64 `json:"memPct" example:"45.2"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||
NetUp uint64 `json:"netUp" example:"2097152"`
|
||||
NetDown uint64 `json:"netDown" example:"1048576"`
|
||||
LastError string `json:"lastError" example:""`
|
||||
XrayState string `json:"xrayState" example:"running"`
|
||||
XrayError string `json:"xrayError" example:""`
|
||||
ConfigDirty bool `json:"configDirty" example:"false"`
|
||||
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
|
||||
InboundCount int `json:"inboundCount" example:"3"`
|
||||
ClientCount int `json:"clientCount" example:"25"`
|
||||
OnlineCount int `json:"onlineCount" example:"5"`
|
||||
ActiveCount int `json:"activeCount" example:"20"`
|
||||
DisabledCount int `json:"disabledCount" example:"2"`
|
||||
DepletedCount int `json:"depletedCount" example:"1"`
|
||||
Guid string `json:"guid" example:"node-guid"`
|
||||
Status string `json:"status" example:"online"`
|
||||
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
|
||||
LatencyMs int `json:"latencyMs" example:"42"`
|
||||
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
||||
// SingboxVersion is the bound agent's sing-box core version
|
||||
// (V2.0.1 ②), overlaid from agent telemetry like XrayVersion;
|
||||
// empty for traditional nodes and agents that never reported
|
||||
// one. The nodes page prefers it over XrayVersion when set.
|
||||
SingboxVersion string `json:"singboxVersion" example:"1.13.21"`
|
||||
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
|
||||
CpuPct float64 `json:"cpuPct" example:"12.5"`
|
||||
MemPct float64 `json:"memPct" example:"45.2"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||
NetUp uint64 `json:"netUp" example:"2097152"`
|
||||
NetDown uint64 `json:"netDown" example:"1048576"`
|
||||
LastError string `json:"lastError" example:""`
|
||||
XrayState string `json:"xrayState" example:"running"`
|
||||
XrayError string `json:"xrayError" example:""`
|
||||
ConfigDirty bool `json:"configDirty" example:"false"`
|
||||
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
|
||||
InboundCount int `json:"inboundCount" example:"3"`
|
||||
ClientCount int `json:"clientCount" example:"25"`
|
||||
OnlineCount int `json:"onlineCount" example:"5"`
|
||||
ActiveCount int `json:"activeCount" example:"20"`
|
||||
DisabledCount int `json:"disabledCount" example:"2"`
|
||||
DepletedCount int `json:"depletedCount" example:"1"`
|
||||
// Per-category client email lists behind the counts above, from the
|
||||
// same attribution bucketing, so the nodes page category chips can
|
||||
// pop over the actual usernames. Empty when the category is empty.
|
||||
@@ -74,10 +79,10 @@ type NodeView struct {
|
||||
ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"`
|
||||
DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"`
|
||||
DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"`
|
||||
ParentGuid string `json:"parentGuid,omitempty" example:""`
|
||||
Transitive bool `json:"transitive,omitempty" example:"false"`
|
||||
CreatedAt int64 `json:"createdAt" example:"1700000000"`
|
||||
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
|
||||
ParentGuid string `json:"parentGuid,omitempty" example:""`
|
||||
Transitive bool `json:"transitive,omitempty" example:"false"`
|
||||
CreatedAt int64 `json:"createdAt" example:"1700000000"`
|
||||
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
|
||||
}
|
||||
|
||||
func toNodeView(n *model.Node) *NodeView {
|
||||
@@ -226,6 +231,9 @@ func annotateAgentTelemetry(views []*NodeView) {
|
||||
if ag.XrayVersion != "" {
|
||||
v.XrayVersion = ag.XrayVersion
|
||||
}
|
||||
if ag.SingboxVersion != "" {
|
||||
v.SingboxVersion = ag.SingboxVersion
|
||||
}
|
||||
if ag.AgentVersion != "" {
|
||||
// New agents report the A-series display version, shown
|
||||
// verbatim; older agents report the internal build version
|
||||
|
||||
@@ -30,6 +30,15 @@ type Meta struct {
|
||||
CountryPin string
|
||||
IPv4Pin string
|
||||
IPv6Pin string
|
||||
// TrafficCalUpGB / TrafficCalDownGB are the operator's ledger
|
||||
// corrections in GB (V2.0.1), either sign: added to the counted
|
||||
// uplink (tx) / downlink (rx) figures wherever the ledger is
|
||||
// read — panel statistics, the public page and traffic alerts.
|
||||
TrafficCalUpGB float64
|
||||
TrafficCalDownGB float64
|
||||
// StatIface pins the one interface whose counters the agent
|
||||
// books (V2.0.1); "" keeps the automatic counted-set selection.
|
||||
StatIface string
|
||||
}
|
||||
|
||||
// DefaultMeta returns the metadata of an agent that has no row yet —
|
||||
@@ -48,7 +57,8 @@ func DefaultMeta(agentID int64) Meta {
|
||||
|
||||
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`
|
||||
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin,
|
||||
traffic_cal_up_gb, traffic_cal_down_gb, stat_iface`
|
||||
|
||||
func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
||||
var m Meta
|
||||
@@ -56,7 +66,8 @@ func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
||||
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 {
|
||||
&m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin,
|
||||
&m.TrafficCalUpGB, &m.TrafficCalDownGB, &m.StatIface); err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
if expires.Valid && strings.TrimSpace(expires.String) != "" {
|
||||
@@ -107,7 +118,7 @@ func (s *Store) UpsertMeta(m Meta) error {
|
||||
expires = *m.ExpiresAt
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`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,
|
||||
@@ -116,10 +127,14 @@ func (s *Store) UpsertMeta(m Meta) error {
|
||||
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`,
|
||||
ipv6_pin=excluded.ipv6_pin,
|
||||
traffic_cal_up_gb=excluded.traffic_cal_up_gb,
|
||||
traffic_cal_down_gb=excluded.traffic_cal_down_gb,
|
||||
stat_iface=excluded.stat_iface`,
|
||||
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)
|
||||
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin,
|
||||
m.TrafficCalUpGB, m.TrafficCalDownGB, m.StatIface)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// V2.0.1 probe batch, storage side: the calibration columns and the
|
||||
// counted-interface pin round-trip through probe_meta, pre-V2.0.1
|
||||
// databases gain the columns on open, and the traffic reads apply
|
||||
// the calibration (floored at zero) on every figure.
|
||||
|
||||
func TestMetaCalibrationAndIfaceRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
meta := DefaultMeta(7)
|
||||
meta.TrafficCalUpGB = 2.5
|
||||
meta.TrafficCalDownGB = -1.25
|
||||
meta.StatIface = "eth0"
|
||||
if err := st.UpsertMeta(meta); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
back, found, err := st.Meta(7)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("meta read: found=%v err=%v", found, err)
|
||||
}
|
||||
if back.TrafficCalUpGB != 2.5 || back.TrafficCalDownGB != -1.25 || back.StatIface != "eth0" {
|
||||
t.Errorf("round trip = up %v down %v iface %q", back.TrafficCalUpGB, back.TrafficCalDownGB, back.StatIface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesLegacyMetaColumns(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "probe.db")
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open raw: %v", err)
|
||||
}
|
||||
// A pre-V2.0.1 probe_meta: the original 16 columns only (shape
|
||||
// copied from the V2.0.0 schema in git history).
|
||||
if _, err := db.Exec(`CREATE TABLE probe_meta (
|
||||
agent_id INTEGER PRIMARY KEY, price REAL NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'USD', billing_cycle TEXT NOT NULL DEFAULT 'monthly',
|
||||
expires_at TEXT, traffic_limit INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_mode TEXT NOT NULL DEFAULT 'sum', traffic_reset_day INTEGER NOT NULL DEFAULT 1,
|
||||
public INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0,
|
||||
group_name TEXT NOT NULL DEFAULT '', public_remark TEXT NOT NULL DEFAULT '',
|
||||
notify INTEGER NOT NULL DEFAULT 0, country_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv4_pin TEXT NOT NULL DEFAULT '', ipv6_pin TEXT NOT NULL DEFAULT '')`); err != nil {
|
||||
t.Fatalf("create legacy table: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO probe_meta (agent_id, group_name) VALUES (3, 'legacy')`); err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
back, found, err := st.Meta(3)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("meta read: found=%v err=%v", found, err)
|
||||
}
|
||||
if back.GroupName != "legacy" || back.TrafficCalUpGB != 0 || back.TrafficCalDownGB != 0 || back.StatIface != "" {
|
||||
t.Errorf("legacy row after migration = %+v", back)
|
||||
}
|
||||
back.StatIface = "ens3"
|
||||
if err := st.UpsertMeta(back); err != nil {
|
||||
t.Fatalf("upsert after migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCalibrationAppliedAndFloored(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
// Book 3 GB down / 1 GB up of counted traffic (baseline, delta).
|
||||
book(t, st, 5, "k1/fp", 0, 0, now, nil, 0)
|
||||
book(t, st, 5, "k1/fp", 3_000_000_000, 1_000_000_000, now.Add(time.Minute), nil, now.Unix())
|
||||
|
||||
meta := DefaultMeta(5)
|
||||
meta.TrafficCalUpGB = -2 // correction exceeds the counted upload
|
||||
meta.TrafficCalDownGB = 0.5 // half a GB the counters missed
|
||||
if err := st.UpsertMeta(meta); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
all, err := st.AllTraffic()
|
||||
if err != nil {
|
||||
t.Fatalf("AllTraffic: %v", err)
|
||||
}
|
||||
tr := all[5]
|
||||
if tr.TotalRx != 3_500_000_000 || tr.MonthRx != 3_500_000_000 || tr.DayRx != 3_500_000_000 {
|
||||
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3500000000 each", tr.TotalRx, tr.MonthRx, tr.DayRx)
|
||||
}
|
||||
if tr.TotalTx != 0 || tr.MonthTx != 0 || tr.DayTx != 0 {
|
||||
t.Errorf("tx figures after -2 GB calibration = %d/%d/%d, want 0 (floored)", tr.TotalTx, tr.MonthTx, tr.DayTx)
|
||||
}
|
||||
|
||||
one, found, err := st.TrafficFor(5)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("TrafficFor: found=%v err=%v", found, err)
|
||||
}
|
||||
if one.TotalRx != tr.TotalRx || one.TotalTx != tr.TotalTx {
|
||||
t.Errorf("TrafficFor disagrees with AllTraffic: %+v vs %+v", one, tr)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,12 @@ CREATE TABLE IF NOT EXISTS probe_meta (
|
||||
notify INTEGER NOT NULL DEFAULT 0,
|
||||
country_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv4_pin TEXT NOT NULL DEFAULT '',
|
||||
ipv6_pin TEXT NOT NULL DEFAULT ''
|
||||
ipv6_pin TEXT NOT NULL DEFAULT '',
|
||||
-- V2.0.1 probe batch: operator traffic calibration (GB, either
|
||||
-- sign) and the counted-interface pin ('' = automatic selection).
|
||||
traffic_cal_up_gb REAL NOT NULL DEFAULT 0,
|
||||
traffic_cal_down_gb REAL NOT NULL DEFAULT 0,
|
||||
stat_iface TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Monotonic byte counters that survive agent reboots and master
|
||||
@@ -204,9 +209,55 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("probe schema: %w", err)
|
||||
}
|
||||
if err := migrateMetaColumns(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("probe schema migration: %w", err)
|
||||
}
|
||||
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
|
||||
}
|
||||
|
||||
// metaColumnMigrations are the probe_meta columns added after the
|
||||
// table's first release; CREATE TABLE IF NOT EXISTS never retrofits
|
||||
// them onto an existing database, so they are added here, once, by
|
||||
// name check (SQLite has no ADD COLUMN IF NOT EXISTS).
|
||||
var metaColumnMigrations = []struct{ name, ddl string }{
|
||||
{"traffic_cal_up_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_up_gb REAL NOT NULL DEFAULT 0`},
|
||||
{"traffic_cal_down_gb", `ALTER TABLE probe_meta ADD COLUMN traffic_cal_down_gb REAL NOT NULL DEFAULT 0`},
|
||||
{"stat_iface", `ALTER TABLE probe_meta ADD COLUMN stat_iface TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
|
||||
func migrateMetaColumns(db *sql.DB) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(probe_meta)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
have[name] = true
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range metaColumnMigrations {
|
||||
if have[m.name] {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(m.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the database handle.
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
|
||||
@@ -320,15 +320,53 @@ func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) {
|
||||
if err != nil {
|
||||
return Traffic{}, false, err
|
||||
}
|
||||
return gate(t, dayStart, resetDay, time.Now()), true, nil
|
||||
gated := gate(t, dayStart, resetDay, time.Now())
|
||||
if meta, ok, err := s.Meta(agentID); err != nil {
|
||||
return Traffic{}, false, err
|
||||
} else if ok {
|
||||
gated = applyCalibration(gated, meta.TrafficCalUpGB, meta.TrafficCalDownGB)
|
||||
}
|
||||
return gated, true, nil
|
||||
}
|
||||
|
||||
// AllTraffic returns every agent's ledger in one query, gated as in
|
||||
// TrafficFor — the bulk read port for B3's node list.
|
||||
// calBytes converts a GB calibration value to bytes on the panel's
|
||||
// 1 GB = 1e9 B convention (the same one the traffic limit uses).
|
||||
func calBytes(gb float64) int64 {
|
||||
return int64(math.Round(gb * 1e9))
|
||||
}
|
||||
|
||||
// applyCalibration shifts a ledger row by the operator's per-node
|
||||
// corrections (V2.0.1): uplink correction on every tx figure,
|
||||
// downlink on every rx figure. A correction may exceed the counted
|
||||
// value (a node re-imaged mid-period); figures floor at zero rather
|
||||
// than going negative.
|
||||
func applyCalibration(t Traffic, calUpGB, calDownGB float64) Traffic {
|
||||
up, down := calBytes(calUpGB), calBytes(calDownGB)
|
||||
if up == 0 && down == 0 {
|
||||
return t
|
||||
}
|
||||
floor := func(v, delta int64) int64 {
|
||||
if v += delta; v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
t.TotalTx = floor(t.TotalTx, up)
|
||||
t.MonthTx = floor(t.MonthTx, up)
|
||||
t.DayTx = floor(t.DayTx, up)
|
||||
t.TotalRx = floor(t.TotalRx, down)
|
||||
t.MonthRx = floor(t.MonthRx, down)
|
||||
t.DayRx = floor(t.DayRx, down)
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *Store) AllTraffic() (map[int64]Traffic, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.agent_id, t.total_rx, t.total_tx, t.month_rx, t.month_tx, t.month_start,
|
||||
t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1)
|
||||
t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1),
|
||||
COALESCE(m.traffic_cal_up_gb, 0), COALESCE(m.traffic_cal_down_gb, 0)
|
||||
FROM probe_traffic t LEFT JOIN probe_meta m ON m.agent_id = t.agent_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -340,11 +378,12 @@ func (s *Store) AllTraffic() (map[int64]Traffic, error) {
|
||||
var id, resetDay int64
|
||||
var t Traffic
|
||||
var dayStart string
|
||||
var calUpGB, calDownGB float64
|
||||
if err := rows.Scan(&id, &t.TotalRx, &t.TotalTx, &t.MonthRx, &t.MonthTx, &t.MonthStart,
|
||||
&t.DayRx, &t.DayTx, &dayStart, &resetDay); err != nil {
|
||||
&t.DayRx, &t.DayTx, &dayStart, &resetDay, &calUpGB, &calDownGB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = gate(t, dayStart, resetDay, now)
|
||||
out[id] = applyCalibration(gate(t, dayStart, resetDay, now), calUpGB, calDownGB)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// ProbeCoverageRow is one agent's cf-probe retirement readiness
|
||||
// (item 7 B6): whether its heartbeats carry the probe block the
|
||||
// public page is fed from. The retirement checklist in the settings
|
||||
// tab renders from these rows — the operator verifies every node is
|
||||
// covered by the in-panel probe before standing the old cf-probe
|
||||
// fleet down, node by node.
|
||||
type ProbeCoverageRow struct {
|
||||
Name string `json:"name"`
|
||||
Online bool `json:"online"`
|
||||
HasProbe bool `json:"hasProbe"`
|
||||
ProbeAt int64 `json:"probeAt"` // unix ms of the last probe-carrying heartbeat, 0 = never
|
||||
}
|
||||
|
||||
// ProbeAgentCoverage lists every agent with its probe coverage. The
|
||||
// display name follows the public view's rule (the bound node's name
|
||||
// first, the agent's own name otherwise); online is the public
|
||||
// view's 150s freshness window.
|
||||
func ProbeAgentCoverage() []ProbeCoverageRow {
|
||||
db := database.GetDB()
|
||||
var agents []model.AgentNode
|
||||
if err := db.Find(&agents).Error; err != nil {
|
||||
return []ProbeCoverageRow{}
|
||||
}
|
||||
nodeNames := map[int]string{}
|
||||
var nodes []model.Node
|
||||
if err := db.Find(&nodes).Error; err == nil {
|
||||
for _, n := range nodes {
|
||||
nodeNames[n.Id] = n.Name
|
||||
}
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
out := make([]ProbeCoverageRow, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
name := a.Name
|
||||
if a.NodeID != nil {
|
||||
if bound, ok := nodeNames[*a.NodeID]; ok && bound != "" {
|
||||
name = bound
|
||||
}
|
||||
}
|
||||
out = append(out, ProbeCoverageRow{
|
||||
Name: name,
|
||||
Online: a.LastSeen > 0 && nowMs-a.LastSeen <= 150_000,
|
||||
HasProbe: a.ProbeAt > 0,
|
||||
ProbeAt: a.ProbeAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -56,6 +57,25 @@ func setProbeSetting(key, value string) error {
|
||||
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
|
||||
|
||||
@@ -17,9 +17,12 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
)
|
||||
|
||||
// Item 7 B5: the theme store (hub frontend.rs + api.rs theme
|
||||
@@ -716,23 +719,31 @@ func SetProbeIconSetting(key, value string) error {
|
||||
return setProbeSetting(key, value)
|
||||
}
|
||||
|
||||
// ProbePublicSettings is the public-surface settings block the theme
|
||||
// tab edits (settings KV keys, hub names).
|
||||
// ProbePublicSettings is the public-surface settings block the probe
|
||||
// settings page edits (settings KV keys, hub names). RetentionDays
|
||||
// and FastIntervalSec joined in V2.0.1: the retention window already
|
||||
// existed as the retention_days key (read by the probe store's
|
||||
// housekeeping), it only lacked an editing surface; the fast-report
|
||||
// interval drives the agents' lightweight metrics cadence.
|
||||
type ProbePublicSettings struct {
|
||||
PublicPage bool `json:"publicPage"`
|
||||
SiteName string `json:"siteName"`
|
||||
Site string `json:"site"`
|
||||
GitHubProxy string `json:"githubProxy"`
|
||||
PublicPage bool `json:"publicPage"`
|
||||
SiteName string `json:"siteName"`
|
||||
Site string `json:"site"`
|
||||
GitHubProxy string `json:"githubProxy"`
|
||||
RetentionDays int64 `json:"retentionDays"`
|
||||
FastIntervalSec int `json:"fastIntervalSec"`
|
||||
}
|
||||
|
||||
// GetProbePublicSettings reads the settings block.
|
||||
func GetProbePublicSettings() ProbePublicSettings {
|
||||
proxy, _ := probeSetting("github_proxy")
|
||||
return ProbePublicSettings{
|
||||
PublicPage: ProbePublicPageEnabled(),
|
||||
SiteName: ProbeSiteName(),
|
||||
Site: ProbeSiteURL(),
|
||||
GitHubProxy: proxy,
|
||||
PublicPage: ProbePublicPageEnabled(),
|
||||
SiteName: ProbeSiteName(),
|
||||
Site: ProbeSiteURL(),
|
||||
GitHubProxy: proxy,
|
||||
RetentionDays: probe.RetentionDays(),
|
||||
FastIntervalSec: ProbeFastIntervalSec(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +753,12 @@ func SetProbePublicSettings(s ProbePublicSettings) error {
|
||||
if s.GitHubProxy != "" && !strings.HasPrefix(s.GitHubProxy, "https://") {
|
||||
return themeRefuse("GitHub 代理要以 https:// 开头,或者留空")
|
||||
}
|
||||
if s.RetentionDays < 1 || s.RetentionDays > 365 {
|
||||
return themeRefuse("历史保留天数要在 1 到 365 之间")
|
||||
}
|
||||
if s.FastIntervalSec < 1 || s.FastIntervalSec > 60 {
|
||||
return themeRefuse("快报间隔要在 1 到 60 秒之间")
|
||||
}
|
||||
if err := SetProbePublicPageEnabled(s.PublicPage); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -751,6 +768,12 @@ func SetProbePublicSettings(s ProbePublicSettings) error {
|
||||
if err := setProbeSetting("site", s.Site); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setProbeSetting("retention_days", strconv.FormatInt(s.RetentionDays, 10)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setProbeSetting("probe_fast_interval", strconv.Itoa(s.FastIntervalSec)); err != nil {
|
||||
return err
|
||||
}
|
||||
return setProbeSetting("github_proxy", s.GitHubProxy)
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,15 @@ func (s *SingboxService) GetSingboxConfigForAgent(nodeID int, singboxOutboundsJS
|
||||
if len(fragment) == 0 {
|
||||
continue
|
||||
}
|
||||
// V2.0.1 ⓪: the row's listen/port are authoritative at
|
||||
// generation time. The stored fragment is a snapshot (taken
|
||||
// at migration or at the last save's recompute) and drifts
|
||||
// whenever the row was edited through a path that did not
|
||||
// recompute it — V2.0.0's converter also omitted "listen"
|
||||
// entirely for an empty row listen, which sing-box reads as
|
||||
// loopback-only, silently unbinding migrated inbounds from
|
||||
// the network. The row always wins here.
|
||||
applyRowListenOverride(inbound, fragment)
|
||||
preserved := singbox.Preserved{}
|
||||
if strings.TrimSpace(inbound.SingboxPreserved) != "" {
|
||||
if err := json.Unmarshal([]byte(inbound.SingboxPreserved), &preserved); err != nil {
|
||||
@@ -140,6 +149,32 @@ func (s *SingboxService) GetSingboxConfigForAgent(nodeID int, singboxOutboundsJS
|
||||
}), nil
|
||||
}
|
||||
|
||||
// applyRowListenOverride stamps the inbound row's listen address and
|
||||
// port onto a sing-box fragment, replacing whatever the snapshot
|
||||
// carried (V2.0.1 ⓪). An empty row listen means "all interfaces" in
|
||||
// the panel's model, which sing-box spells "::" (dual-stack
|
||||
// wildcard); an absent listen field would default to loopback-only,
|
||||
// so the wildcard is always written explicitly. A specific row
|
||||
// listen (one IPv6 address, an internal address, ...) is carried
|
||||
// verbatim — sing-box then binds exactly that address. WireGuard
|
||||
// endpoints take no listen address, only the port.
|
||||
func applyRowListenOverride(inbound *model.Inbound, fragment map[string]any) {
|
||||
if fragment["type"] == "wireguard" {
|
||||
if inbound.Port > 0 {
|
||||
fragment["listen_port"] = inbound.Port
|
||||
}
|
||||
return
|
||||
}
|
||||
if inbound.Listen != "" {
|
||||
fragment["listen"] = inbound.Listen
|
||||
} else {
|
||||
fragment["listen"] = "::"
|
||||
}
|
||||
if inbound.Port > 0 {
|
||||
fragment["listen_port"] = inbound.Port
|
||||
}
|
||||
}
|
||||
|
||||
// filterEnabledClients applies the xray generation path's client
|
||||
// gates, in the same order and with the same log line: a client whose
|
||||
// traffic row is disabled (expiration or traffic limit) is dropped,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"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/singbox"
|
||||
)
|
||||
|
||||
// RecomputeSingboxFragment re-runs the sing-box converter over an
|
||||
// inbound row's current panel material and stores the fresh fragment
|
||||
// + preserved sidecar (V2.0.1 ⓪). Before this, the fragment was a
|
||||
// one-shot migration snapshot: panel edits after the migration (a new
|
||||
// Reality target, a changed listen/port, ...) never reached it, so
|
||||
// the agent kept serving the migration-day material while the panel
|
||||
// showed the edit as saved. The identity layer is read back after the
|
||||
// caller's own sync, so the fragment's embedded users match what
|
||||
// generation would project anyway; the row listen/port override is
|
||||
// stamped on top exactly as generation applies it.
|
||||
func (s *SingboxService) RecomputeSingboxFragment(inbound *model.Inbound) error {
|
||||
attached, err := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clients := toSingboxClients(attached)
|
||||
fillConverterOnlyClientFields(inbound, attached, clients)
|
||||
|
||||
fallbackMaps, err := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fallbacks := make([]json.RawMessage, 0, len(fallbackMaps))
|
||||
for _, fm := range fallbackMaps {
|
||||
raw, err := json.Marshal(fm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fallbacks = append(fallbacks, raw)
|
||||
}
|
||||
|
||||
res, err := singbox.ConvertInbound(singbox.Inbound{
|
||||
Listen: inbound.Listen,
|
||||
Port: inbound.Port,
|
||||
Protocol: string(inbound.Protocol),
|
||||
Tag: inbound.Tag,
|
||||
DisableFlow: inbound.DisableFlow,
|
||||
Settings: json.RawMessage(inbound.Settings),
|
||||
StreamSettings: json.RawMessage(inbound.StreamSettings),
|
||||
Sniffing: json.RawMessage(inbound.Sniffing),
|
||||
Fallbacks: fallbacks,
|
||||
Clients: clients,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fragJSON, err := res.MarshalFragment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fragJSON != "" {
|
||||
fragment := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(fragJSON), &fragment); err != nil {
|
||||
return err
|
||||
}
|
||||
applyRowListenOverride(inbound, fragment)
|
||||
raw, err := json.Marshal(fragment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fragJSON = string(raw)
|
||||
}
|
||||
presJSON, err := res.MarshalPreserved()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return database.GetDB().Model(&model.Inbound{}).Where("id = ?", inbound.Id).Updates(map[string]any{
|
||||
"singbox_config": fragJSON, "singbox_preserved": presJSON,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// fillConverterOnlyClientFields restores the client fields the
|
||||
// identity projection (toSingboxClients) does not carry but the
|
||||
// converter reads: VMess alterId lives only in the settings.clients
|
||||
// mirror (the identity layer has no column for it — the B1 migrator
|
||||
// sourced it from the same mirror), and a VLESS client's reverse
|
||||
// block reaches the identity projection as a parsed tag, re-rendered
|
||||
// here in the converter's verbatim-JSON shape.
|
||||
func fillConverterOnlyClientFields(inbound *model.Inbound, attached []model.Client, clients []singbox.Client) {
|
||||
alterByEmail := map[string]int{}
|
||||
var settings struct {
|
||||
Clients []struct {
|
||||
Email string `json:"email"`
|
||||
AlterID int `json:"alterId"`
|
||||
} `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err == nil {
|
||||
for _, c := range settings.Clients {
|
||||
if c.AlterID != 0 {
|
||||
alterByEmail[c.Email] = c.AlterID
|
||||
}
|
||||
}
|
||||
}
|
||||
reverseByEmail := map[string]string{}
|
||||
for _, c := range attached {
|
||||
if c.Reverse != nil && c.Reverse.Tag != "" {
|
||||
reverseByEmail[c.Email] = c.Reverse.Tag
|
||||
}
|
||||
}
|
||||
for i := range clients {
|
||||
if alt, ok := alterByEmail[clients[i].Email]; ok {
|
||||
clients[i].AlterID = alt
|
||||
}
|
||||
if tag, ok := reverseByEmail[clients[i].Email]; ok {
|
||||
if raw, err := json.Marshal(map[string]string{"tag": tag}); err == nil {
|
||||
clients[i].Reverse = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeSingboxFragmentLogged is the save-path hook: a failed
|
||||
// recompute must not fail the panel save itself (the row is already
|
||||
// persisted and generation still overrides listen/port from it), but
|
||||
// it is logged loudly — a silent skip here is exactly how the
|
||||
// V2.0.0 fragment drift stayed invisible.
|
||||
func recomputeSingboxFragmentLogged(inbound *model.Inbound) {
|
||||
svc := &SingboxService{inboundService: InboundService{}}
|
||||
if err := svc.RecomputeSingboxFragment(inbound); err != nil {
|
||||
logger.Warningf("sing-box fragment recompute for inbound %d (%s) failed: %v", inbound.Id, inbound.Tag, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// V2.0.1 ⓪ end-to-end regression for the loopback blind spot that
|
||||
// let the V2 defect ship: a generated config is run under a real
|
||||
// sing-box binary and connected to through the machine's
|
||||
// NON-loopback address, not just 127.0.0.1 (the B5 drill connected
|
||||
// loopback-only and never saw that an absent listen field binds
|
||||
// loopback in sing-box). The negative control — the same config
|
||||
// with the listen key removed, the pre-fix shape — must come up
|
||||
// bound to loopback in the core's own start line, proving the test
|
||||
// can actually catch the defect.
|
||||
//
|
||||
// Bind scope is asserted from the core's "tcp server started at"
|
||||
// log line: this sandbox delivers connections to any local address
|
||||
// (loopback-bound python http.server included) when dialing the
|
||||
// machine's own IP, so a dial alone cannot discriminate bind scope
|
||||
// here — on a real node it does, which is exactly how the defect
|
||||
// presented in production (external clients timed out while ss
|
||||
// showed the truth). The dials remain as reachability smoke for
|
||||
// both addresses.
|
||||
//
|
||||
// Gated on SINGBOX_E2E_BIN pointing at a sing-box binary (the
|
||||
// self-built v1.13.21 release build in production); skipped
|
||||
// otherwise so the unit suite stays hermetic.
|
||||
func TestSingboxListenNonLoopbackE2E(t *testing.T) {
|
||||
bin := os.Getenv("SINGBOX_E2E_BIN")
|
||||
if bin == "" {
|
||||
t.Skip("SINGBOX_E2E_BIN not set")
|
||||
}
|
||||
lanIP := firstNonLoopbackIPv4(t)
|
||||
|
||||
setupBulkDB(t)
|
||||
nodeID := 43
|
||||
node := &model.Node{Id: nodeID, Name: "e2e-node", Address: "192.0.2.1", Port: 8443, Enable: true, Status: "offline"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
clients := []model.Client{{Email: "e2e@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "se", Enable: true}}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "listen-e2e",
|
||||
Enable: true,
|
||||
Protocol: model.VLESS,
|
||||
Port: 29871,
|
||||
Listen: "",
|
||||
Tag: "inbound-29871",
|
||||
NodeID: &nodeID,
|
||||
Settings: clientsSettings(t, clients),
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatalf("seed client linkage: %v", err)
|
||||
}
|
||||
|
||||
// The V2.0.1 save path computes the fragment; generation then
|
||||
// serves it. This is the exact chain a panel edit travels.
|
||||
singboxSvc := &SingboxService{}
|
||||
if err := singboxSvc.RecomputeSingboxFragment(inbound); err != nil {
|
||||
t.Fatalf("RecomputeSingboxFragment: %v", err)
|
||||
}
|
||||
cfg, err := singboxSvc.GetSingboxConfigForAgent(nodeID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSingboxConfigForAgent: %v", err)
|
||||
}
|
||||
frag := firstInboundFrag(t, cfg)
|
||||
if frag["listen"] != "::" {
|
||||
t.Fatalf("generated listen = %v, want ::", frag["listen"])
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
runAndDial := func(t *testing.T, name string, cfg map[string]any, port int) (loopOK, lanOK bool, startedAt string) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, fmt.Sprintf("cfg-%s.json", name))
|
||||
if err := os.WriteFile(cfgPath, raw, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
if out, err := exec.Command(bin, "check", "-c", cfgPath).CombinedOutput(); err != nil {
|
||||
t.Fatalf("sing-box check: %v\n%s", err, out)
|
||||
}
|
||||
logPath := filepath.Join(dir, fmt.Sprintf("run-%s.log", name))
|
||||
logFile, _ := os.Create(logPath)
|
||||
cmd := exec.Command(bin, "run", "-c", cfgPath)
|
||||
cmd.Stdout, cmd.Stderr = logFile, logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start sing-box: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
_ = logFile.Close()
|
||||
}()
|
||||
dial := func(addr string) bool {
|
||||
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
loopAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
lanAddr := fmt.Sprintf("%s:%d", lanIP, port)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
loopOK = dial(loopAddr)
|
||||
lanOK = dial(lanAddr)
|
||||
if loopOK && lanOK {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
// The core's own start line is the bind-scope evidence.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
logBytes, _ := os.ReadFile(logPath)
|
||||
for _, line := range strings.Split(string(logBytes), "\n") {
|
||||
if strings.Contains(line, "tcp server started at") {
|
||||
startedAt = strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
return loopOK, lanOK, startedAt
|
||||
}
|
||||
|
||||
// Positive: the generated config answers on both addresses and
|
||||
// the core reports a wildcard bind.
|
||||
loopOK, lanOK, startedAt := runAndDial(t, "gen", cfg, 29871)
|
||||
if !loopOK {
|
||||
t.Error("generated config does not answer on 127.0.0.1:29871")
|
||||
}
|
||||
if !lanOK {
|
||||
t.Errorf("generated config does not answer on the non-loopback address %s:29871", lanIP)
|
||||
}
|
||||
if !strings.Contains(startedAt, "tcp server started at [::]:29871") {
|
||||
t.Errorf("generated config bind line = %q, want the core started at [::]:29871", startedAt)
|
||||
}
|
||||
|
||||
// Negative control: strip the listen key (the pre-fix fragment
|
||||
// shape). The core must report a loopback bind — the exact
|
||||
// production defect — while the fixed config above does not.
|
||||
raw, _ := json.Marshal(cfg)
|
||||
var control map[string]any
|
||||
if err := json.Unmarshal(raw, &control); err != nil {
|
||||
t.Fatalf("clone config: %v", err)
|
||||
}
|
||||
delete(firstInboundFrag(t, control), "listen")
|
||||
loopOK, _, startedAt = runAndDial(t, "ctl", control, 29871)
|
||||
if !loopOK {
|
||||
t.Error("control config lost loopback too — the negative control is not isolating the listen behaviour")
|
||||
}
|
||||
if !strings.Contains(startedAt, "tcp server started at 127.0.0.1:29871") {
|
||||
t.Errorf("control bind line = %q, want the core started at 127.0.0.1:29871 (the pre-fix loopback default)", startedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func firstInboundFrag(t *testing.T, cfg map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
inbounds, _ := cfg["inbounds"].([]any)
|
||||
if len(inbounds) == 0 {
|
||||
t.Fatalf("generated config has no inbounds: %v", cfg)
|
||||
}
|
||||
frag, _ := inbounds[0].(map[string]any)
|
||||
if frag == nil {
|
||||
t.Fatalf("generated inbound is not an object: %v", inbounds[0])
|
||||
}
|
||||
return frag
|
||||
}
|
||||
|
||||
func firstNonLoopbackIPv4(t *testing.T) string {
|
||||
t.Helper()
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
t.Fatalf("interface addrs: %v", err)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipNet, ok := a.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip := ipNet.IP.To4()
|
||||
if ip != nil && !ip.IsLoopback() {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
t.Skip("no non-loopback IPv4 on this machine")
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// V2.0.1 ⓪: the row's listen/port rule the generated sing-box config.
|
||||
// These tests pin the override function itself, the generation-time
|
||||
// application over a stale stored fragment, and the save-time
|
||||
// fragment recompute wired into UpdateInbound.
|
||||
|
||||
func TestApplyRowListenOverride(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
row *model.Inbound
|
||||
fragment map[string]any
|
||||
wantListen any // nil = key must be absent
|
||||
wantPort float64
|
||||
}{
|
||||
{
|
||||
name: "empty row listen binds the dual-stack wildcard",
|
||||
row: &model.Inbound{Listen: "", Port: 8443},
|
||||
fragment: map[string]any{"type": "vless", "tag": "in-1"},
|
||||
wantListen: "::",
|
||||
wantPort: 8443,
|
||||
},
|
||||
{
|
||||
name: "specific IPv4 listen is kept verbatim",
|
||||
row: &model.Inbound{Listen: "192.0.2.10", Port: 443},
|
||||
fragment: map[string]any{"type": "vless", "tag": "in-1", "listen": "0.0.0.0"},
|
||||
wantListen: "192.0.2.10",
|
||||
wantPort: 443,
|
||||
},
|
||||
{
|
||||
name: "specific IPv6 listen is kept verbatim",
|
||||
row: &model.Inbound{Listen: "2001:db8::5", Port: 443},
|
||||
fragment: map[string]any{"type": "shadowsocks", "tag": "in-1"},
|
||||
wantListen: "2001:db8::5",
|
||||
wantPort: 443,
|
||||
},
|
||||
{
|
||||
name: "row port overrides a stale fragment port",
|
||||
row: &model.Inbound{Listen: "", Port: 9999},
|
||||
fragment: map[string]any{"type": "vless", "tag": "in-1", "listen_port": float64(1111)},
|
||||
wantListen: "::",
|
||||
wantPort: 9999,
|
||||
},
|
||||
{
|
||||
name: "wireguard endpoint gains no listen key, port only",
|
||||
row: &model.Inbound{Listen: "192.0.2.10", Port: 51820},
|
||||
fragment: map[string]any{"type": "wireguard", "tag": "in-1"},
|
||||
wantListen: nil,
|
||||
wantPort: 51820,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
applyRowListenOverride(tc.row, tc.fragment)
|
||||
got, present := tc.fragment["listen"]
|
||||
if tc.wantListen == nil {
|
||||
if present {
|
||||
t.Errorf("listen = %v, want the key absent", got)
|
||||
}
|
||||
} else if !present || got != tc.wantListen {
|
||||
t.Errorf("listen = %v (present %v), want %v", got, present, tc.wantListen)
|
||||
}
|
||||
if p := numOf(tc.fragment["listen_port"]); p != tc.wantPort {
|
||||
t.Errorf("listen_port = %v, want %v", tc.fragment["listen_port"], tc.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSingboxGenerationAppliesRowListenOverStaleFragment is the
|
||||
// regression shape of the V2 defect itself: a stored fragment
|
||||
// without a listen key (the migration-era snapshot) must reach the
|
||||
// agent bound to the wildcard, on the row's current port — not
|
||||
// sing-box's loopback default on a stale port.
|
||||
func TestSingboxGenerationAppliesRowListenOverStaleFragment(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
nodeID := 41
|
||||
inbound := &model.Inbound{
|
||||
Remark: "listen-regression",
|
||||
Enable: true,
|
||||
Protocol: model.VLESS,
|
||||
Port: 47158,
|
||||
Tag: "inbound-47158",
|
||||
NodeID: &nodeID,
|
||||
// Migration-era snapshot: no listen key, stale port.
|
||||
SingboxConfig: `{"type":"vless","tag":"inbound-47158","listen_port":1111,` +
|
||||
`"users":[{"name":"a@example.com","uuid":"00000000-0000-0000-0000-000000000001"}]}`,
|
||||
}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
svc := &SingboxService{}
|
||||
cfg, err := svc.GetSingboxConfigForAgent(nodeID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSingboxConfigForAgent: %v", err)
|
||||
}
|
||||
inbounds, _ := cfg["inbounds"].([]any)
|
||||
if len(inbounds) != 1 {
|
||||
t.Fatalf("generated inbounds = %d, want 1", len(inbounds))
|
||||
}
|
||||
frag, _ := inbounds[0].(map[string]any)
|
||||
if frag["listen"] != "::" {
|
||||
t.Errorf("generated listen = %v, want \"::\" (an absent listen binds loopback only in sing-box)", frag["listen"])
|
||||
}
|
||||
if numOf(frag["listen_port"]) != 47158 {
|
||||
t.Errorf("generated listen_port = %v, want the row port 47158", frag["listen_port"])
|
||||
}
|
||||
}
|
||||
|
||||
// numOf reads a JSON-ish numeric (int / float64 / json.Number)
|
||||
// without caring which shape the producer used.
|
||||
func numOf(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
case json.Number:
|
||||
f, _ := n.Float64()
|
||||
return f
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TestRecomputeSingboxFragmentOnUpdate drives the save path: a row
|
||||
// edited in the panel (listen + port changed) must have its stored
|
||||
// fragment recomputed so the next agent poll serves the new binding.
|
||||
func TestRecomputeSingboxFragmentOnUpdate(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
nodeID := 42
|
||||
node := &model.Node{Id: nodeID, Name: "listen-node", Address: "192.0.2.1", Port: 8443, Enable: true, Status: "offline"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
clients := []model.Client{{Email: "u@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "s1", Enable: true}}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "recompute",
|
||||
Enable: true,
|
||||
Protocol: model.VLESS,
|
||||
Port: 23456,
|
||||
Listen: "",
|
||||
Tag: "inbound-23456",
|
||||
NodeID: &nodeID,
|
||||
Settings: clientsSettings(t, clients),
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
// The identity layer the converter reads is the client linkage,
|
||||
// seeded the way the client flows seed it.
|
||||
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
|
||||
t.Fatalf("seed client linkage: %v", err)
|
||||
}
|
||||
|
||||
singboxSvc := &SingboxService{}
|
||||
if err := singboxSvc.RecomputeSingboxFragment(inbound); err != nil {
|
||||
t.Fatalf("RecomputeSingboxFragment: %v", err)
|
||||
}
|
||||
frag := storedFragment(t, inbound.Id)
|
||||
if frag["listen"] != "::" || numOf(frag["listen_port"]) != 23456 {
|
||||
t.Fatalf("fragment after first recompute: listen=%v port=%v, want ::/23456", frag["listen"], frag["listen_port"])
|
||||
}
|
||||
if users, _ := frag["users"].([]any); len(users) != 1 {
|
||||
t.Fatalf("fragment users = %v, want the row's one client", frag["users"])
|
||||
}
|
||||
|
||||
// Panel edit: bind a specific address and move the port.
|
||||
edited := *inbound
|
||||
edited.Listen = "192.0.2.10"
|
||||
edited.Port = 23457
|
||||
if _, _, err := (&InboundService{}).UpdateInbound(&edited); err != nil {
|
||||
t.Fatalf("UpdateInbound: %v", err)
|
||||
}
|
||||
frag = storedFragment(t, inbound.Id)
|
||||
if frag["listen"] != "192.0.2.10" || numOf(frag["listen_port"]) != 23457 {
|
||||
t.Errorf("fragment after update: listen=%v port=%v, want 192.0.2.10/23457", frag["listen"], frag["listen_port"])
|
||||
}
|
||||
}
|
||||
|
||||
func storedFragment(t *testing.T, inboundID int) map[string]any {
|
||||
t.Helper()
|
||||
var row model.Inbound
|
||||
if err := database.GetDB().First(&row, inboundID).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
if row.SingboxConfig == "" {
|
||||
t.Fatalf("stored singbox_config is empty")
|
||||
}
|
||||
frag := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(row.SingboxConfig), &frag); err != nil {
|
||||
t.Fatalf("unmarshal stored fragment: %v", err)
|
||||
}
|
||||
return frag
|
||||
}
|
||||
Reference in New Issue
Block a user